Compare commits

...

10 commits

Author SHA1 Message Date
432ef1a72c Add link to sample instance 2025-04-25 08:53:45 -03:00
ab43295b29 Improved tag searching 2025-04-22 19:55:48 -03:00
8c9bdbb58e Search by media type 2025-04-22 19:30:56 -03:00
e679d167fc Better error pages and clean up dangling tags 2025-04-21 13:46:35 -03:00
4c1a8a9489 Version 0.1.0 2025-04-20 19:01:42 -03:00
261623960e Delete pool 2025-04-20 11:57:45 -03:00
7553dd31dc Generate favicon from post 2025-04-20 11:09:58 -03:00
94269d82f0 Bulk edit tags 2025-04-20 10:24:34 -03:00
8fac396d7e RSS for posts 2025-04-19 09:03:40 -03:00
bb118f6144 Bug fixes 2025-04-14 23:12:14 -03:00
35 changed files with 1078 additions and 286 deletions

146
Cargo.lock generated
View file

@ -370,6 +370,19 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "atom_syndication"
version = "0.12.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3"
dependencies = [
"chrono",
"derive_builder",
"diligent-date-parser",
"never",
"quick-xml",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -953,6 +966,7 @@ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim",
"syn 2.0.100", "syn 2.0.100",
] ]
@ -988,6 +1002,37 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.100",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -1000,6 +1045,15 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "diligent-date-parser"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9"
dependencies = [
"chrono",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@ -1356,6 +1410,25 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "h2"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "half" name = "half"
version = "2.5.0" version = "2.5.0"
@ -1507,6 +1580,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@ -1974,14 +2048,6 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "migration"
version = "0.1.0"
dependencies = [
"async-std",
"sea-orm-migration",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@ -2042,6 +2108,12 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "never"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91"
[[package]] [[package]]
name = "new_debug_unreachable" name = "new_debug_unreachable"
version = "1.0.6" version = "1.0.6"
@ -2498,6 +2570,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369"
dependencies = [
"encoding_rs",
"memchr",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"
@ -2780,6 +2862,18 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rss"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf"
dependencies = [
"atom_syndication",
"derive_builder",
"never",
"quick-xml",
]
[[package]] [[package]]
name = "rust-embed" name = "rust-embed"
version = "8.7.0" version = "8.7.0"
@ -2944,15 +3038,17 @@ dependencies = [
"futures-util", "futures-util",
"image", "image",
"itertools 0.14.0", "itertools 0.14.0",
"migration",
"mime_guess", "mime_guess",
"password-auth", "password-auth",
"pulldown-cmark", "pulldown-cmark",
"rand 0.9.0", "rand 0.9.0",
"rss",
"rust-embed", "rust-embed",
"samey-migration",
"sea-orm", "sea-orm",
"serde", "serde",
"serde_json", "serde_json",
"strum 0.27.1",
"thiserror 2.0.12", "thiserror 2.0.12",
"time", "time",
"tokio", "tokio",
@ -2960,6 +3056,14 @@ dependencies = [
"tower-sessions", "tower-sessions",
] ]
[[package]]
name = "samey-migration"
version = "0.1.0"
dependencies = [
"async-std",
"sea-orm-migration",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@ -3000,7 +3104,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"strum", "strum 0.26.3",
"thiserror 2.0.12", "thiserror 2.0.12",
"time", "time",
"tracing", "tracing",
@ -3575,6 +3679,28 @@ version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
[[package]]
name = "strum"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.100",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"

View file

@ -6,14 +6,18 @@ rust-version = "1.85"
license = "MIT" license = "MIT"
authors = ["Bad Manners <me@badmanners.xyz>"] authors = ["Bad Manners <me@badmanners.xyz>"]
readme = "README.md" readme = "README.md"
keywords = ["booru", "image-board", "gallery"]
categories = ["web-programming"]
description = "Sam's small image board"
repository = "https://github.com/BadMannersXYZ/samey"
[workspace] [workspace]
members = [".", "migration"] members = ["migration"]
[dependencies] [dependencies]
askama = { version = "0.13.0", features = ["serde_json"] } askama = { version = "0.13.0", features = ["serde_json"] }
async-trait = "0.1.88" async-trait = "0.1.88"
axum = { version = "0.8.3", features = ["multipart", "macros"] } axum = { version = "0.8.3", features = ["http2", "multipart", "macros"] }
axum-extra = { version = "0.10.1", features = ["form"] } axum-extra = { version = "0.10.1", features = ["form"] }
axum-login = "0.17.0" axum-login = "0.17.0"
chrono = "0.4.40" chrono = "0.4.40"
@ -21,11 +25,12 @@ clap = "4.5.35"
futures-util = "0.3.31" futures-util = "0.3.31"
image = "0.25.6" image = "0.25.6"
itertools = "0.14.0" itertools = "0.14.0"
migration = { path = "migration" } samey-migration = { path = "./migration", version = "0.1.0" }
mime_guess = "2.0.5" mime_guess = "2.0.5"
password-auth = "1.0.0" password-auth = "1.0.0"
pulldown-cmark = "0.13.0" pulldown-cmark = "0.13.0"
rand = "0.9.0" rand = "0.9.0"
rss = "2.0.12"
rust-embed = { version = "8.7.0", features = ["axum", "debug-embed"] } rust-embed = { version = "8.7.0", features = ["axum", "debug-embed"] }
sea-orm = { version = "1.1.8", features = [ sea-orm = { version = "1.1.8", features = [
"sqlx-sqlite", "sqlx-sqlite",
@ -40,6 +45,7 @@ time = "0.3.41"
tokio = { version = "1.44.1", features = ["full"] } tokio = { version = "1.44.1", features = ["full"] }
tower-http = { version = "0.6.2", features = ["fs"] } tower-http = { version = "0.6.2", features = ["fs"] }
tower-sessions = "0.14.0" tower-sessions = "0.14.0"
strum = { version = "0.27.1", features = ["derive"] }
[profile.release] [profile.release]
strip = true strip = true

View file

@ -2,29 +2,35 @@
Sam's small image board. Sam's small image board.
[Check out a sample instance here!](https://samey.badmanners.xyz/)
## Status ## Status
Still very much an early WIP. Still very much an early WIP.
### Roadmap ### Features
- [ ] Logging - Image and video posts.
- [ ] Improved error handling - Tagging with autocompletion.
- [ ] Caching - Post pools.
- [ ] Lossless compression - RSS feeds.
- [ ] Bulk edit tags/Fix tag capitalization
- [ ] User management ### Possible roadmap
- [ ] Cleanup/fixup background tasks
- [ ] Display thumbnails on post selection
- [ ] Text media - [ ] Text media
- [ ] Testing
- [ ] Improve CSS - [ ] Improve CSS
- [ ] User management
- [ ] Lossless compression
- [ ] Migrate to Cot...? - [ ] Migrate to Cot...?
## Prerequisites
- `ffmpeg` and `ffprobe`
## Running ## Running
### Dependencies
- `ffmpeg` (with `ffprobe`)
### Development ### Development
```bash ```bash

View file

@ -1,6 +1,8 @@
services: services:
samey: samey:
image: badmanners/samey:latest image: badmanners/samey:latest
build:
context: .
container_name: samey container_name: samey
restart: unless-stopped restart: unless-stopped
ports: ports:

View file

@ -1,16 +1,19 @@
[package] [package]
name = "migration" name = "samey-migration"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
publish = false rust-version = "1.85"
license = "MIT"
[lib] authors = ["Bad Manners <me@badmanners.xyz>"]
name = "migration" readme = "README.md"
path = "src/lib.rs" keywords = ["booru", "image-board", "gallery"]
categories = ["web-programming"]
description = "Migrations for Samey, Sam's small image board"
repository = "https://github.com/BadMannersXYZ/samey"
[dependencies] [dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] } async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
sea-orm-migration = { version = "1.1.8", features = [
[dependencies.sea-orm-migration] "sqlx-sqlite",
version = "1.1.8" "runtime-tokio-rustls",
features = ["sqlx-sqlite", "runtime-tokio-rustls"] ] }

View file

@ -2,5 +2,5 @@ use sea_orm_migration::prelude::*;
#[async_std::main] #[async_std::main]
async fn main() { async fn main() {
cli::run_cli(migration::Migrator).await; cli::run_cli(samey_migration::Migrator).await;
} }

View file

@ -1,8 +1,8 @@
use std::fmt::Debug; use std::fmt::Debug;
use axum_login::{AuthUser, AuthnBackend, UserId}; use axum_login::{AuthUser, AuthnBackend, UserId};
use migration::Expr;
use password_auth::verify_password; use password_auth::verify_password;
use samey_migration::Expr;
use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use serde::Deserialize; use serde::Deserialize;
use time::OffsetDateTime; use time::OffsetDateTime;
@ -221,3 +221,9 @@ impl ExpiredDeletion for SessionStorage {
Ok(()) Ok(())
} }
} }
impl From<axum_login::Error<Backend>> for SameyError {
fn from(value: axum_login::Error<Backend>) -> Self {
value.into()
}
}

View file

@ -6,11 +6,13 @@ use crate::{
}; };
pub(crate) const APPLICATION_NAME_KEY: &str = "APPLICATION_NAME"; pub(crate) const APPLICATION_NAME_KEY: &str = "APPLICATION_NAME";
pub(crate) const BASE_URL_KEY: &str = "BASE_URL";
pub(crate) const AGE_CONFIRMATION_KEY: &str = "AGE_CONFIRMATION"; pub(crate) const AGE_CONFIRMATION_KEY: &str = "AGE_CONFIRMATION";
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct AppConfig { pub(crate) struct AppConfig {
pub(crate) application_name: String, pub(crate) application_name: String,
pub(crate) base_url: String,
pub(crate) age_confirmation: bool, pub(crate) age_confirmation: bool,
} }
@ -24,6 +26,14 @@ impl AppConfig {
Some(row) => row.data.as_str().unwrap_or("Samey").to_owned(), Some(row) => row.data.as_str().unwrap_or("Samey").to_owned(),
None => "Samey".to_owned(), None => "Samey".to_owned(),
}; };
let base_url = match SameyConfig::find()
.filter(samey_config::Column::Key.eq(BASE_URL_KEY))
.one(db)
.await?
{
Some(row) => row.data.as_str().unwrap_or("").to_owned(),
None => "".to_owned(),
};
let age_confirmation = match SameyConfig::find() let age_confirmation = match SameyConfig::find()
.filter(samey_config::Column::Key.eq(AGE_CONFIRMATION_KEY)) .filter(samey_config::Column::Key.eq(AGE_CONFIRMATION_KEY))
.one(db) .one(db)
@ -34,6 +44,7 @@ impl AppConfig {
}; };
Ok(Self { Ok(Self {
application_name, application_name,
base_url,
age_confirmation, age_confirmation,
}) })
} }

View file

@ -4,54 +4,114 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
#[derive(askama::Template)]
#[template(path = "pages/bad_request.html")]
struct BadRequestTemplate<'a> {
error: &'a str,
}
#[derive(askama::Template)]
#[template(path = "pages/unauthorized.html")]
struct UnauthorizedTemplate;
#[derive(askama::Template)]
#[template(path = "pages/forbidden.html")]
struct ForbiddenTemplate;
#[derive(askama::Template)] #[derive(askama::Template)]
#[template(path = "pages/not_found.html")] #[template(path = "pages/not_found.html")]
struct NotFoundTemplate; struct NotFoundTemplate;
#[derive(askama::Template)]
#[template(path = "pages/internal_server_error.html")]
struct InternalServerErrorTemplate;
/// Errors from Samey.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum SameyError { pub enum SameyError {
/// Integer conversion error.
#[error("Integer conversion error: {0}")] #[error("Integer conversion error: {0}")]
IntConversion(#[from] std::num::TryFromIntError), IntConversion(#[from] std::num::TryFromIntError),
/// Integer parsing error.
#[error("Integer parsing error: {0}")]
IntParse(#[from] std::num::ParseIntError),
/// IO error.
#[error("IO error: {0}")] #[error("IO error: {0}")]
IO(#[from] std::io::Error), IO(#[from] std::io::Error),
/// Task error.
#[error("Task error: {0}")] #[error("Task error: {0}")]
Join(#[from] tokio::task::JoinError), Join(#[from] tokio::task::JoinError),
/// Template render error.
#[error("Template render error: {0}")] #[error("Template render error: {0}")]
Render(#[from] askama::Error), Render(#[from] askama::Error),
/// Database error.
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] sea_orm::error::DbErr), Database(#[from] sea_orm::error::DbErr),
/// File streaming error.
#[error("File streaming error: {0}")] #[error("File streaming error: {0}")]
Multipart(#[from] axum::extract::multipart::MultipartError), Multipart(#[from] axum::extract::multipart::MultipartError),
/// Image error.
#[error("Image error: {0}")] #[error("Image error: {0}")]
Image(#[from] image::ImageError), Image(#[from] image::ImageError),
#[error("Not found")] /// Authentication error.
NotFound,
#[error("Authentication error: {0}")] #[error("Authentication error: {0}")]
Authentication(String), Authentication(String),
/// Not found.
#[error("Not found")]
NotFound,
/// Not allowed.
#[error("Not allowed")] #[error("Not allowed")]
Forbidden, Forbidden,
/// Bad request.
#[error("Bad request: {0}")] #[error("Bad request: {0}")]
BadRequest(String), BadRequest(String),
/// Custom internal error.
#[error("Internal error: {0}")] #[error("Internal error: {0}")]
Other(String), Other(String),
} }
impl IntoResponse for SameyError { impl IntoResponse for SameyError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
println!("Server error - {}", &self);
match &self { match &self {
SameyError::IntConversion(_) SameyError::IntConversion(_)
| SameyError::IntParse(_)
| SameyError::IO(_) | SameyError::IO(_)
| SameyError::Join(_) | SameyError::Join(_)
| SameyError::Render(_) | SameyError::Render(_)
| SameyError::Database(_) | SameyError::Database(_)
| SameyError::Image(_) | SameyError::Image(_)
| SameyError::Other(_) => { | SameyError::Other(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong!").into_response() println!("Internal server error - {:?}", &self);
} (
SameyError::Multipart(_) | SameyError::BadRequest(_) => { StatusCode::INTERNAL_SERVER_ERROR,
(StatusCode::BAD_REQUEST, "Invalid request").into_response() Html(
InternalServerErrorTemplate {}
.render()
.expect("shouldn't fail to render InternalServerErrorTemplate"),
),
)
.into_response()
} }
SameyError::Multipart(error) => (
StatusCode::BAD_REQUEST,
Html(
BadRequestTemplate {
error: &error.body_text(),
}
.render()
.expect("shouldn't fail to render BadRequestTemplate"),
),
)
.into_response(),
SameyError::BadRequest(error) => (
StatusCode::BAD_REQUEST,
Html(
BadRequestTemplate { error }
.render()
.expect("shouldn't fail to render BadRequestTemplate"),
),
)
.into_response(),
SameyError::NotFound => ( SameyError::NotFound => (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Html( Html(
@ -61,10 +121,24 @@ impl IntoResponse for SameyError {
), ),
) )
.into_response(), .into_response(),
SameyError::Authentication(_) => { SameyError::Authentication(_) => (
(StatusCode::UNAUTHORIZED, "Not authorized").into_response() StatusCode::UNAUTHORIZED,
} Html(
SameyError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden").into_response(), UnauthorizedTemplate {}
.render()
.expect("shouldn't fail to render UnauthorizedTemplate"),
),
)
.into_response(),
SameyError::Forbidden => (
StatusCode::FORBIDDEN,
Html(
ForbiddenTemplate {}
.render()
.expect("shouldn't fail to render ForbiddenTemplate"),
),
)
.into_response(),
} }
} }
} }

View file

@ -1,9 +1,11 @@
//! Sam's small image board.
pub(crate) mod auth; pub(crate) mod auth;
pub(crate) mod config; pub(crate) mod config;
pub(crate) mod entities; pub(crate) mod entities;
pub(crate) mod error; pub(crate) mod error;
pub(crate) mod query; pub(crate) mod query;
pub(crate) mod rating; pub(crate) mod tags;
pub(crate) mod video; pub(crate) mod video;
pub(crate) mod views; pub(crate) mod views;
@ -33,9 +35,6 @@ use crate::entities::{prelude::SameyUser, samey_user};
pub use crate::error::SameyError; pub use crate::error::SameyError;
use crate::views::*; use crate::views::*;
pub(crate) const NEGATIVE_PREFIX: &str = "-";
pub(crate) const RATING_PREFIX: &str = "rating:";
#[derive(rust_embed::Embed)] #[derive(rust_embed::Embed)]
#[folder = "static/"] #[folder = "static/"]
struct Asset; struct Asset;
@ -63,14 +62,26 @@ pub(crate) struct AppState {
app_config: Arc<RwLock<AppConfig>>, app_config: Arc<RwLock<AppConfig>>,
} }
/// Helper function to create a single user.
///
/// You can specify if they must be an admin user via the `is_admin` flag.
///
/// ```
/// use samey::create_user;
///
/// # async fn _main() {
/// let db = sea_orm::Database::connect("sqlite:db.sqlite3?mode=rwc").await.unwrap();
/// create_user(db, "admin", "secretPassword", true).await.expect("Unable to add admin user");
/// # }
/// ```
pub async fn create_user( pub async fn create_user(
db: DatabaseConnection, db: DatabaseConnection,
username: String, username: &str,
password: String, password: &str,
is_admin: bool, is_admin: bool,
) -> Result<(), SameyError> { ) -> Result<(), SameyError> {
SameyUser::insert(samey_user::ActiveModel { SameyUser::insert(samey_user::ActiveModel {
username: Set(username), username: Set(username.into()),
password: Set(generate_hash(password)), password: Set(generate_hash(password)),
is_admin: Set(is_admin), is_admin: Set(is_admin),
..Default::default() ..Default::default()
@ -80,6 +91,18 @@ pub async fn create_user(
Ok(()) Ok(())
} }
/// Creates an Axum router for a Samey application.
///
/// ```
/// use samey::get_router;
///
/// # async fn _main() {
/// let db = sea_orm::Database::connect("sqlite:db.sqlite3?mode=rwc").await.unwrap();
/// let app = get_router(db, "files").await.unwrap();
/// let listener = tokio::net::TcpListener::bind(("0.0.0.0", 3000)).await.unwrap();
/// axum::serve(listener, app).await.unwrap();
/// # }
/// ```
pub async fn get_router( pub async fn get_router(
db: DatabaseConnection, db: DatabaseConnection,
files_dir: impl AsRef<Path>, files_dir: impl AsRef<Path>,
@ -92,7 +115,9 @@ pub async fn get_router(
fs::create_dir_all(files_dir.as_ref()).await?; fs::create_dir_all(files_dir.as_ref()).await?;
let session_store = SessionStorage::new(db.clone()); let session_store = SessionStorage::new(db.clone());
let session_layer = SessionManagerLayer::new(session_store); let session_layer = SessionManagerLayer::new(session_store).with_expiry(
tower_sessions::Expiry::OnInactivity(time::Duration::weeks(1)),
);
let auth_layer = AuthManagerLayerBuilder::new(Backend::new(db), session_layer).build(); let auth_layer = AuthManagerLayerBuilder::new(Backend::new(db), session_layer).build();
Ok(Router::new() Ok(Router::new()
@ -121,12 +146,14 @@ pub async fn get_router(
.route_with_tsr("/pools", get(get_pools)) .route_with_tsr("/pools", get(get_pools))
.route_with_tsr("/pools/{page}", get(get_pools_page)) .route_with_tsr("/pools/{page}", get(get_pools_page))
.route_with_tsr("/pool", post(create_pool)) .route_with_tsr("/pool", post(create_pool))
.route_with_tsr("/pool/{pool_id}", get(view_pool)) .route_with_tsr("/pool/{pool_id}", get(view_pool).delete(delete_pool))
.route_with_tsr("/pool/{pool_id}/name", put(change_pool_name)) .route_with_tsr("/pool/{pool_id}/name", put(change_pool_name))
.route_with_tsr("/pool/{pool_id}/public", put(change_pool_visibility)) .route_with_tsr("/pool/{pool_id}/public", put(change_pool_visibility))
.route_with_tsr("/pool/{pool_id}/post", post(add_post_to_pool)) .route_with_tsr("/pool/{pool_id}/post", post(add_post_to_pool))
.route_with_tsr("/pool/{pool_id}/sort", put(sort_pool)) .route_with_tsr("/pool/{pool_id}/sort", put(sort_pool))
.route_with_tsr("/pool_post/{pool_post_id}", delete(remove_pool_post)) .route_with_tsr("/pool_post/{pool_post_id}", delete(remove_pool_post))
// Bulk edit tag routes
.route_with_tsr("/bulk_edit_tag", get(bulk_edit_tag).post(edit_tag))
// Settings routes // Settings routes
.route_with_tsr("/settings", get(settings).post(update_settings)) .route_with_tsr("/settings", get(settings).post(update_settings))
// Search routes // Search routes
@ -134,6 +161,7 @@ pub async fn get_router(
.route_with_tsr("/posts/{page}", get(posts_page)) .route_with_tsr("/posts/{page}", get(posts_page))
// Other routes // Other routes
.route_with_tsr("/remove", delete(remove_field)) .route_with_tsr("/remove", delete(remove_field))
.route("/posts.xml", get(rss_page))
.route("/", get(index)) .route("/", get(index))
.with_state(state) .with_state(state)
.nest_service("/files", ServeDir::new(files_dir)) .nest_service("/files", ServeDir::new(files_dir))

View file

@ -1,54 +1,89 @@
use std::{
net::{IpAddr, Ipv6Addr},
path::PathBuf,
};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use migration::{Migrator, MigratorTrait};
use samey::{create_user, get_router}; use samey::{create_user, get_router};
use samey_migration::{Migrator, MigratorTrait};
use sea_orm::Database; use sea_orm::Database;
#[derive(Parser)] #[derive(Parser)]
struct Config { struct Config {
#[arg(short, long, default_value = "sqlite:db.sqlite3?mode=rwc")]
database: String,
#[arg(short, long, default_value = "files")]
files_directory: PathBuf,
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
Run, Run {
#[arg(short, long, default_value_t = IpAddr::V6(Ipv6Addr::UNSPECIFIED))]
address: IpAddr,
#[arg(short, long, default_value_t = 3000)]
port: u16,
},
Migrate, Migrate,
AddAdminUser { AddAdminUser {
#[arg(short, long)] #[arg(short, long)]
username: String, username: String,
#[arg(short, long)] #[arg(short, long)]
password: String, password: String,
}, },
} }
impl Default for Commands {
fn default() -> Self {
Commands::Run {
address: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
port: 3000,
}
}
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let db = Database::connect("sqlite:db.sqlite3?mode=rwc") let config = Config::parse();
let db = Database::connect(config.database)
.await .await
.expect("Unable to connect to database"); .expect("Unable to connect to database");
let config = Config::parse(); match config.command.unwrap_or_default() {
match config.command { Commands::Migrate => {
Some(Commands::Migrate) => {
Migrator::up(&db, None) Migrator::up(&db, None)
.await .await
.expect("Unable to apply migrations"); .expect("Unable to apply migrations");
} }
Some(Commands::AddAdminUser { username, password }) => {
create_user(db, username, password, true) Commands::AddAdminUser { username, password } => {
create_user(db, &username, &password, true)
.await .await
.expect("Unable to add admin user"); .expect("Unable to add admin user");
} }
Some(Commands::Run) | None => {
Commands::Run { address, port } => {
Migrator::up(&db, None) Migrator::up(&db, None)
.await .await
.expect("Unable to apply migrations"); .expect("Unable to apply migrations");
let app = get_router(db, "files") let app = get_router(db, config.files_directory)
.await .await
.expect("Unable to start router"); .expect("Unable to start router");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") let listener = tokio::net::TcpListener::bind((address, port))
.await .await
.expect("Unable to listen to port"); .expect("Unable to bind TCP listener");
println!("Listening on http://localhost:3000"); if address.is_ipv6() {
println!("Listening on http://[{}]:{}", address, port);
} else {
println!("Listening on http://{}:{}", address, port);
}
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
} }

View file

@ -1,25 +1,31 @@
use std::collections::HashSet; use std::collections::HashSet;
use migration::{Expr, Query}; use chrono::NaiveDateTime;
use samey_migration::{Expr, Query};
use sea_orm::{ use sea_orm::{
ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoSimpleExpr, ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoIdentity,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns, SelectModel, IntoSimpleExpr, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns,
Selector, SelectModel, Selector,
}; };
use crate::{ use crate::{
NEGATIVE_PREFIX, RATING_PREFIX, SameyError, SameyError,
auth::User, auth::User,
entities::{ entities::{
prelude::{SameyPool, SameyPoolPost, SameyPost, SameyTag, SameyTagPost}, prelude::{SameyPool, SameyPoolPost, SameyPost, SameyTag, SameyTagPost},
samey_pool, samey_pool_post, samey_post, samey_tag, samey_tag_post, samey_pool, samey_pool_post, samey_post, samey_tag, samey_tag_post,
}, },
tags::{MEDIA_TYPE_PREFIX, NEGATIVE_PREFIX, RATING_PREFIX},
}; };
#[derive(Debug, FromQueryResult)] #[derive(Debug, FromQueryResult)]
pub(crate) struct PostOverview { pub(crate) struct PostOverview {
pub(crate) id: i32, pub(crate) id: i32,
pub(crate) thumbnail: String, pub(crate) thumbnail: String,
pub(crate) media: String,
pub(crate) title: Option<String>,
pub(crate) description: Option<String>,
pub(crate) uploaded_at: NaiveDateTime,
pub(crate) tags: Option<String>, pub(crate) tags: Option<String>,
pub(crate) media_type: String, pub(crate) media_type: String,
pub(crate) rating: String, pub(crate) rating: String,
@ -33,17 +39,23 @@ pub(crate) fn search_posts(
let mut exclude_tags = HashSet::<String>::new(); let mut exclude_tags = HashSet::<String>::new();
let mut include_ratings = HashSet::<String>::new(); let mut include_ratings = HashSet::<String>::new();
let mut exclude_ratings = HashSet::<String>::new(); let mut exclude_ratings = HashSet::<String>::new();
let mut include_types = HashSet::<String>::new();
let mut exclude_types = HashSet::<String>::new();
if let Some(tags) = tags { if let Some(tags) = tags {
for mut tag in tags.iter().map(|tag| tag.to_lowercase()) { for tag in tags.iter().map(|tag| tag.to_lowercase()) {
if tag.starts_with(NEGATIVE_PREFIX) { if let Some(negative_tag) = tag.strip_prefix(NEGATIVE_PREFIX) {
if tag.as_str()[NEGATIVE_PREFIX.len()..].starts_with(RATING_PREFIX) { if let Some(negative_rating_tag) = negative_tag.strip_prefix(RATING_PREFIX) {
exclude_ratings exclude_ratings.insert(negative_rating_tag.into());
.insert(tag.split_off(NEGATIVE_PREFIX.len() + RATING_PREFIX.len())); } else if let Some(negative_type_tag) = negative_tag.strip_prefix(MEDIA_TYPE_PREFIX)
{
exclude_types.insert(negative_type_tag.into());
} else { } else {
exclude_tags.insert(tag.split_off(NEGATIVE_PREFIX.len())); exclude_tags.insert(negative_tag.into());
} }
} else if tag.starts_with(RATING_PREFIX) { } else if let Some(rating_tag) = tag.strip_prefix(RATING_PREFIX) {
include_ratings.insert(tag.split_off(RATING_PREFIX.len())); include_ratings.insert(rating_tag.into());
} else if let Some(type_tag) = tag.strip_prefix(MEDIA_TYPE_PREFIX) {
include_types.insert(type_tag.into());
} else { } else {
include_tags.insert(tag); include_tags.insert(tag);
} }
@ -54,6 +66,10 @@ pub(crate) fn search_posts(
let mut query = SameyPost::find() let mut query = SameyPost::find()
.select_only() .select_only()
.column(samey_post::Column::Id) .column(samey_post::Column::Id)
.column(samey_post::Column::Media)
.column(samey_post::Column::Title)
.column(samey_post::Column::Description)
.column(samey_post::Column::UploadedAt)
.column(samey_post::Column::Thumbnail) .column(samey_post::Column::Thumbnail)
.column(samey_post::Column::Rating) .column(samey_post::Column::Rating)
.column(samey_post::Column::MediaType) .column(samey_post::Column::MediaType)
@ -72,11 +88,21 @@ pub(crate) fn search_posts(
if !exclude_ratings.is_empty() { if !exclude_ratings.is_empty() {
query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings)) query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings))
} }
if !include_types.is_empty() {
query = query.filter(samey_post::Column::MediaType.is_in(include_types))
}
if !exclude_types.is_empty() {
query = query.filter(samey_post::Column::MediaType.is_not_in(exclude_types))
}
query query
} else { } else {
let mut query = SameyPost::find() let mut query = SameyPost::find()
.select_only() .select_only()
.column(samey_post::Column::Id) .column(samey_post::Column::Id)
.column(samey_post::Column::Media)
.column(samey_post::Column::Title)
.column(samey_post::Column::Description)
.column(samey_post::Column::UploadedAt)
.column(samey_post::Column::Thumbnail) .column(samey_post::Column::Thumbnail)
.column(samey_post::Column::Rating) .column(samey_post::Column::Rating)
.column(samey_post::Column::MediaType) .column(samey_post::Column::MediaType)
@ -134,6 +160,12 @@ pub(crate) fn search_posts(
if !exclude_ratings.is_empty() { if !exclude_ratings.is_empty() {
query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings)) query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings))
} }
if !include_types.is_empty() {
query = query.filter(samey_post::Column::MediaType.is_in(include_types))
}
if !exclude_types.is_empty() {
query = query.filter(samey_post::Column::MediaType.is_not_in(exclude_types))
}
query query
}; };
@ -263,3 +295,18 @@ pub(crate) fn filter_posts_by_user(
), ),
} }
} }
pub(crate) async fn clean_dangling_tags(db: &DatabaseConnection) -> Result<(), SameyError> {
let dangling_tags = SameyTag::find()
.select_column_as(samey_tag_post::Column::Id.count(), "count")
.left_join(SameyTagPost)
.group_by(samey_tag::Column::Id)
.having(Expr::column("count".into_identity()).eq(0))
.all(db)
.await?;
SameyTag::delete_many()
.filter(samey_tag::Column::Id.is_in(dangling_tags.into_iter().map(|tag| tag.id)))
.exec(db)
.await?;
Ok(())
}

View file

@ -1,31 +0,0 @@
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"),
}
}
}

23
src/tags.rs Normal file
View file

@ -0,0 +1,23 @@
pub(crate) const NEGATIVE_PREFIX: &str = "-";
pub(crate) const RATING_PREFIX: &str = "rating:";
pub(crate) const MEDIA_TYPE_PREFIX: &str = "type:";
#[derive(strum::EnumIter, strum::Display, Debug)]
pub(crate) enum Rating {
#[strum(serialize = "u")]
Unrated,
#[strum(serialize = "s")]
Safe,
#[strum(serialize = "q")]
Questionable,
#[strum(serialize = "e")]
Explicit,
}
#[derive(strum::EnumIter, strum::Display, Debug)]
pub(crate) enum MediaType {
#[strum(serialize = "image")]
Image,
#[strum(serialize = "video")]
Video,
}

View file

@ -8,7 +8,7 @@ pub(crate) fn generate_thumbnail(
max_thumbnail_dimension: u32, max_thumbnail_dimension: u32,
) -> Result<(), SameyError> { ) -> Result<(), SameyError> {
let status = Command::new("ffmpeg") let status = Command::new("ffmpeg")
.args(&[ .args([
"-i", "-i",
input_path, input_path,
"-vf", "-vf",
@ -39,7 +39,7 @@ pub(crate) fn generate_thumbnail(
pub(crate) fn get_dimensions_for_video(input_path: &str) -> Result<(u32, u32), SameyError> { pub(crate) fn get_dimensions_for_video(input_path: &str) -> Result<(u32, u32), SameyError> {
let output = Command::new("ffprobe") let output = Command::new("ffprobe")
.args(&[ .args([
"-v", "-v",
"error", "error",
"-select_streams", "-select_streams",

View file

@ -17,19 +17,20 @@ use axum_extra::extract::Form;
use chrono::Utc; use chrono::Utc;
use image::{GenericImageView, ImageFormat, ImageReader}; use image::{GenericImageView, ImageFormat, ImageReader};
use itertools::Itertools; use itertools::Itertools;
use migration::{Expr, OnConflict};
use rand::Rng; use rand::Rng;
use samey_migration::{OnConflict, Query as MigrationQuery};
use sea_orm::{ use sea_orm::{
ActiveValue::Set, ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr, ActiveValue::Set, ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr,
ModelTrait, PaginatorTrait, QueryFilter, QuerySelect, ModelTrait, PaginatorTrait, QueryFilter, QuerySelect,
}; };
use serde::Deserialize; use serde::Deserialize;
use strum::IntoEnumIterator;
use tokio::{task::spawn_blocking, try_join}; use tokio::{task::spawn_blocking, try_join};
use crate::{ use crate::{
AppState, NEGATIVE_PREFIX, RATING_PREFIX, AppState,
auth::{AuthSession, Credentials, User}, auth::{AuthSession, Credentials, User},
config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY}, config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY, BASE_URL_KEY},
entities::{ entities::{
prelude::{ prelude::{
SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag, SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag,
@ -40,9 +41,10 @@ use crate::{
}, },
error::SameyError, error::SameyError,
query::{ query::{
PoolPost, PostOverview, PostPoolData, filter_posts_by_user, get_pool_data_for_post, PoolPost, PostOverview, PostPoolData, clean_dangling_tags, filter_posts_by_user,
get_posts_in_pool, get_tags_for_post, search_posts, get_pool_data_for_post, get_posts_in_pool, get_tags_for_post, search_posts,
}, },
tags::{MEDIA_TYPE_PREFIX, MediaType, NEGATIVE_PREFIX, RATING_PREFIX, Rating},
video::{generate_thumbnail, get_dimensions_for_video}, video::{generate_thumbnail, get_dimensions_for_video},
}; };
@ -90,6 +92,63 @@ pub(crate) async fn index(
)) ))
} }
// RSS view
#[derive(Template)]
#[template(path = "fragments/rss_entry.html")]
struct RssEntryTemplate<'a> {
post: PostOverview,
base_url: &'a str,
}
#[axum::debug_handler]
pub(crate) async fn rss_page(
State(AppState { app_config, db, .. }): State<AppState>,
Query(query): Query<PostsQuery>,
) -> Result<impl IntoResponse, SameyError> {
let app_config = app_config.read().await;
let application_name = app_config.application_name.clone();
let base_url = app_config.base_url.clone();
drop(app_config);
let tags = query
.tags
.as_ref()
.map(|tags| tags.split_whitespace().collect::<Vec<_>>());
let posts = search_posts(tags.as_ref(), None)
.paginate(&db, 20)
.fetch_page(0)
.await?;
let channel = rss::ChannelBuilder::default()
.title(&application_name)
.link(&base_url)
.items(
posts
.into_iter()
.map(|post| {
rss::ItemBuilder::default()
.title(post.tags.clone())
.pub_date(post.uploaded_at.and_utc().to_rfc2822())
.link(format!("{}/post/{}", &base_url, post.id))
.content(
RssEntryTemplate {
post,
base_url: &base_url,
}
.render()
.ok(),
)
.build()
})
.collect_vec(),
)
.build();
Ok(channel.to_string())
}
// Auth views // Auth views
#[derive(Template)] #[derive(Template)]
@ -126,24 +185,17 @@ pub(crate) async fn login(
mut auth_session: AuthSession, mut auth_session: AuthSession,
Form(credentials): Form<Credentials>, Form(credentials): Form<Credentials>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let user = match auth_session.authenticate(credentials).await { let user = match auth_session.authenticate(credentials).await? {
Ok(Some(user)) => user, Some(user) => user,
Ok(None) => return Err(SameyError::Authentication("Invalid credentials".into())), None => return Err(SameyError::Authentication("Invalid credentials".into())),
Err(_) => return Err(SameyError::Other("Auth session error".into())),
}; };
auth_session auth_session.login(&user).await?;
.login(&user)
.await
.map_err(|_| SameyError::Other("Login failed".into()))?;
Ok(Redirect::to("/")) Ok(Redirect::to("/"))
} }
pub(crate) async fn logout(mut auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> { pub(crate) async fn logout(mut auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> {
auth_session auth_session.logout().await?;
.logout()
.await
.map_err(|_| SameyError::Other("Logout error".into()))?;
Ok(Redirect::to("/")) Ok(Redirect::to("/"))
} }
@ -203,10 +255,9 @@ impl FromStr for Format {
"application/x-matroska" | "video/mastroska" => Ok(Self::Video(".mkv")), "application/x-matroska" | "video/mastroska" => Ok(Self::Video(".mkv")),
"video/quicktime" => Ok(Self::Video(".mov")), "video/quicktime" => Ok(Self::Video(".mov")),
_ => Ok(Self::Image( _ => Ok(Self::Image(
ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other(format!( ImageFormat::from_mime_type(content_type).ok_or(SameyError::BadRequest(
"Unknown content type: {}", format!("Unknown content type: {}", content_type),
content_type ))?,
)))?,
)), )),
} }
} }
@ -277,7 +328,7 @@ pub(crate) async fn upload(
"media-file" => { "media-file" => {
let content_type = field let content_type = field
.content_type() .content_type()
.ok_or(SameyError::Other("Missing content type".into()))?; .ok_or(SameyError::BadRequest("Missing content type".into()))?;
match Format::from_str(content_type)? { match Format::from_str(content_type)? {
format @ Format::Video(video_format) => { format @ Format::Video(video_format) => {
media_type = Some(format.media_type()); media_type = Some(format.media_type());
@ -300,6 +351,7 @@ pub(crate) async fn upload(
.read(true) .read(true)
.write(true) .write(true)
.create(true) .create(true)
.truncate(true)
.open(&file_path)?; .open(&file_path)?;
while let Some(chunk) = field.chunk().await? { while let Some(chunk) = field.chunk().await? {
file.write_all(&chunk)?; file.write_all(&chunk)?;
@ -350,6 +402,7 @@ pub(crate) async fn upload(
.read(true) .read(true)
.write(true) .write(true)
.create(true) .create(true)
.truncate(true)
.open(&file_path)?; .open(&file_path)?;
while let Some(chunk) = field.chunk().await? { while let Some(chunk) = field.chunk().await? {
file.write_all(&chunk)?; file.write_all(&chunk)?;
@ -443,7 +496,9 @@ pub(crate) async fn upload(
Ok(Redirect::to(&format!("/post/{}", uploaded_post))) Ok(Redirect::to(&format!("/post/{}", uploaded_post)))
} else { } else {
Err(SameyError::Other("Missing parameters for upload".into())) Err(SameyError::BadRequest(
"Missing parameters for upload".into(),
))
} }
} }
@ -471,32 +526,43 @@ pub(crate) async fn search_tags(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
Form(body): Form<SearchTagsForm>, Form(body): Form<SearchTagsForm>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let tags = match body.tags[..body.selection_end].split(' ').last() { let tags = match body.tags[..body.selection_end].split(' ').next_back() {
Some(mut tag) => { Some(mut tag) => {
tag = tag.trim(); tag = tag.trim();
if tag.is_empty() { if tag.is_empty() {
vec![] vec![]
} else if tag.starts_with(NEGATIVE_PREFIX) { } else if let Some(stripped_tag) = tag.strip_prefix(NEGATIVE_PREFIX) {
if tag[NEGATIVE_PREFIX.len()..].starts_with(RATING_PREFIX) { if stripped_tag.starts_with(RATING_PREFIX) {
[ Rating::iter()
format!("{}u", RATING_PREFIX), .map(|rating| format!("{}{}", RATING_PREFIX, rating))
format!("{}s", RATING_PREFIX), .filter(|t| t.starts_with(stripped_tag))
format!("{}q", RATING_PREFIX), .map(|tag| SearchTag {
format!("{}e", RATING_PREFIX), value: format!("-{}", &tag),
] name: tag,
.into_iter() })
.filter(|t| t.starts_with(&tag[NEGATIVE_PREFIX.len()..])) .collect()
.map(|tag| SearchTag { } else if stripped_tag.starts_with(MEDIA_TYPE_PREFIX) {
value: format!("-{}", &tag), MediaType::iter()
name: tag, .map(|rating| format!("{}{}", MEDIA_TYPE_PREFIX, rating))
}) .filter(|t| t.starts_with(stripped_tag))
.collect() .map(|tag| SearchTag {
value: format!("-{}", &tag),
name: tag,
})
.collect()
} else { } else {
SameyTag::find() SameyTag::find()
.filter(Expr::cust_with_expr( .filter(
"LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')", Condition::any()
tag[NEGATIVE_PREFIX.len()..].to_lowercase(), .add(
)) samey_tag::Column::NormalizedName
.starts_with(stripped_tag.to_lowercase()),
)
.add(
samey_tag::Column::NormalizedName
.contains(format!(":{}", stripped_tag.to_lowercase())),
),
)
.limit(10) .limit(10)
.all(&db) .all(&db)
.await? .await?
@ -508,25 +574,33 @@ pub(crate) async fn search_tags(
.collect() .collect()
} }
} else if tag.starts_with(RATING_PREFIX) { } else if tag.starts_with(RATING_PREFIX) {
[ Rating::iter()
format!("{}u", RATING_PREFIX), .map(|rating| format!("{}{}", RATING_PREFIX, rating))
format!("{}s", RATING_PREFIX), .filter(|t| t.starts_with(tag))
format!("{}q", RATING_PREFIX), .map(|tag| SearchTag {
format!("{}e", RATING_PREFIX), value: tag.clone(),
] name: tag,
.into_iter() })
.filter(|t| t.starts_with(tag)) .collect()
.map(|tag| SearchTag { } else if tag.starts_with(MEDIA_TYPE_PREFIX) {
value: tag.clone(), MediaType::iter()
name: tag, .map(|rating| format!("{}{}", MEDIA_TYPE_PREFIX, rating))
}) .filter(|t| t.starts_with(tag))
.collect() .map(|tag| SearchTag {
value: tag.clone(),
name: tag,
})
.collect()
} else { } else {
SameyTag::find() SameyTag::find()
.filter(Expr::cust_with_expr( .filter(
"LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')", Condition::any()
tag.to_lowercase(), .add(samey_tag::Column::NormalizedName.starts_with(tag.to_lowercase()))
)) .add(
samey_tag::Column::NormalizedName
.contains(format!(":{}", tag.to_lowercase())),
),
)
.limit(10) .limit(10)
.all(&db) .all(&db)
.await? .await?
@ -1058,12 +1132,10 @@ pub(crate) async fn sort_pool(
}; };
let max_index = if body.new_index == posts.len().saturating_sub(1) { let max_index = if body.new_index == posts.len().saturating_sub(1) {
None None
} else if body.new_index < body.old_index {
Some(body.new_index)
} else { } else {
if body.new_index < body.old_index { Some(body.new_index + 1)
Some(body.new_index)
} else {
Some(body.new_index + 1)
}
}; };
let min = min_index.map(|index| posts[index].position).unwrap_or(0.0); let min = min_index.map(|index| posts[index].position).unwrap_or(0.0);
let max = max_index let max = max_index
@ -1091,12 +1163,170 @@ pub(crate) async fn sort_pool(
)) ))
} }
pub(crate) async fn delete_pool(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
let pool = SameyPool::find_by_id(pool_id)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
let can_edit = match auth_session.user.as_ref() {
None => false,
Some(user) => user.is_admin || pool.uploader_id == user.id,
};
if !can_edit {
return Err(SameyError::Forbidden);
}
SameyPool::delete_by_id(pool_id).exec(&db).await?;
Ok(Redirect::to("/"))
}
// Bulk edit tag views
enum BulkEditTagMessage {
None,
Success,
Failure(String),
}
#[derive(Template)]
#[template(path = "pages/bulk_edit_tag.html")]
struct BulkEditTagTemplate {
application_name: String,
age_confirmation: bool,
message: BulkEditTagMessage,
}
pub(crate) async fn bulk_edit_tag(
State(AppState { app_config, .. }): State<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
if auth_session.user.is_none_or(|user| !user.is_admin) {
return Err(SameyError::Forbidden);
}
let app_config = app_config.read().await;
let application_name = app_config.application_name.clone();
let age_confirmation = app_config.age_confirmation;
drop(app_config);
Ok(Html(
BulkEditTagTemplate {
application_name,
age_confirmation,
message: BulkEditTagMessage::None,
}
.render()?,
))
}
#[derive(Debug, Deserialize)]
pub(crate) struct EditTagForm {
tags: String,
new_tag: String,
}
pub(crate) async fn edit_tag(
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Form(body): Form<EditTagForm>,
) -> Result<impl IntoResponse, SameyError> {
if auth_session.user.is_none_or(|user| !user.is_admin) {
return Err(SameyError::Forbidden);
}
let app_config = app_config.read().await;
let application_name = app_config.application_name.clone();
let age_confirmation = app_config.age_confirmation;
drop(app_config);
let old_tag: Vec<_> = body.tags.split_whitespace().collect();
if old_tag.len() != 1 {
return Ok(Html(
BulkEditTagTemplate {
application_name,
age_confirmation,
message: BulkEditTagMessage::Failure("expected single tag to edit".into()),
}
.render()?,
));
}
let old_tag = old_tag.first().unwrap();
let normalized_old_tag = old_tag.to_lowercase();
let new_tag: Vec<_> = body.new_tag.split_whitespace().collect();
if new_tag.len() != 1 {
return Ok(Html(
BulkEditTagTemplate {
application_name,
age_confirmation,
message: BulkEditTagMessage::Failure("expected single new tag".into()),
}
.render()?,
));
}
let new_tag = new_tag.first().unwrap();
let normalized_new_tag = new_tag.to_lowercase();
let old_tag_db = SameyTag::find()
.filter(samey_tag::Column::NormalizedName.eq(&normalized_old_tag))
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
if let Some(new_tag_db) = SameyTag::find()
.filter(samey_tag::Column::NormalizedName.eq(&normalized_new_tag))
.one(&db)
.await?
{
let subquery = MigrationQuery::select()
.column((SameyTagPost, samey_tag_post::Column::PostId))
.from(SameyTagPost)
.and_where(samey_tag_post::Column::TagId.eq(new_tag_db.id))
.to_owned();
SameyTagPost::update_many()
.filter(samey_tag_post::Column::TagId.eq(old_tag_db.id))
.filter(samey_tag_post::Column::PostId.not_in_subquery(subquery))
.set(samey_tag_post::ActiveModel {
tag_id: Set(new_tag_db.id),
..Default::default()
})
.exec(&db)
.await?;
SameyTag::delete_by_id(old_tag_db.id).exec(&db).await?;
} else {
SameyTag::update(samey_tag::ActiveModel {
id: Set(old_tag_db.id),
name: Set(new_tag.to_string()),
normalized_name: Set(normalized_new_tag),
})
.exec(&db)
.await?;
}
Ok(Html(
BulkEditTagTemplate {
application_name,
age_confirmation,
message: BulkEditTagMessage::Success,
}
.render()?,
))
}
// Settings views // Settings views
#[derive(Template)] #[derive(Template)]
#[template(path = "pages/settings.html")] #[template(path = "pages/settings.html")]
struct SettingsTemplate { struct SettingsTemplate {
application_name: String, application_name: String,
base_url: String,
age_confirmation: bool, age_confirmation: bool,
} }
@ -1110,6 +1340,7 @@ pub(crate) async fn settings(
let app_config = app_config.read().await; let app_config = app_config.read().await;
let application_name = app_config.application_name.clone(); let application_name = app_config.application_name.clone();
let base_url = app_config.base_url.clone();
let age_confirmation = app_config.age_confirmation; let age_confirmation = app_config.age_confirmation;
drop(app_config); drop(app_config);
@ -1129,6 +1360,7 @@ pub(crate) async fn settings(
Ok(Html( Ok(Html(
SettingsTemplate { SettingsTemplate {
application_name, application_name,
base_url,
age_confirmation, age_confirmation,
} }
.render_with_values(&values)?, .render_with_values(&values)?,
@ -1138,11 +1370,18 @@ pub(crate) async fn settings(
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct UpdateSettingsForm { pub(crate) struct UpdateSettingsForm {
application_name: String, application_name: String,
base_url: String,
favicon_post_id: String,
age_confirmation: Option<bool>, age_confirmation: Option<bool>,
} }
pub(crate) async fn update_settings( pub(crate) async fn update_settings(
State(AppState { db, app_config, .. }): State<AppState>, State(AppState {
db,
app_config,
files_dir,
..
}): State<AppState>,
auth_session: AuthSession, auth_session: AuthSession,
Form(body): Form<UpdateSettingsForm>, Form(body): Form<UpdateSettingsForm>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
@ -1152,18 +1391,27 @@ pub(crate) async fn update_settings(
let mut configs = vec![]; let mut configs = vec![];
if !body.application_name.is_empty() { let application_name = body.application_name.trim();
if !application_name.is_empty() {
let _ = mem::replace( let _ = mem::replace(
&mut app_config.write().await.application_name, &mut app_config.write().await.application_name,
body.application_name.clone(), application_name.into(),
); );
configs.push(samey_config::ActiveModel { configs.push(samey_config::ActiveModel {
key: Set(APPLICATION_NAME_KEY.into()), key: Set(APPLICATION_NAME_KEY.into()),
data: Set(body.application_name.into()), data: Set(application_name.into()),
..Default::default() ..Default::default()
}); });
} }
let base_url = body.base_url.trim_end_matches('/');
let _ = mem::replace(&mut app_config.write().await.base_url, base_url.into());
configs.push(samey_config::ActiveModel {
key: Set(BASE_URL_KEY.into()),
data: Set(base_url.into()),
..Default::default()
});
let age_confirmation = body.age_confirmation.is_some(); let age_confirmation = body.age_confirmation.is_some();
let _ = mem::replace( let _ = mem::replace(
&mut app_config.write().await.age_confirmation, &mut app_config.write().await.age_confirmation,
@ -1186,6 +1434,21 @@ pub(crate) async fn update_settings(
.await?; .await?;
} }
if let Some(favicon_post_id) = body.favicon_post_id.split_whitespace().next() {
match favicon_post_id.parse::<i32>() {
Ok(favicon_post_id) => {
let post = SameyPost::find_by_id(favicon_post_id)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
ImageReader::open(files_dir.join(post.thumbnail))?
.decode()?
.save_with_format(files_dir.join("favicon.png"), ImageFormat::Png)?;
}
Err(err) => return Err(SameyError::IntParse(err)),
}
}
Ok(Redirect::to("/")) Ok(Redirect::to("/"))
} }
@ -1248,6 +1511,10 @@ pub(crate) async fn view_post_page(
Some(parent_post) => Some(PostOverview { Some(parent_post) => Some(PostOverview {
id: parent_id, id: parent_id,
thumbnail: parent_post.thumbnail, thumbnail: parent_post.thumbnail,
title: parent_post.title,
description: parent_post.description,
uploaded_at: parent_post.uploaded_at,
media: parent_post.media,
tags: Some( tags: Some(
get_tags_for_post(post_id) get_tags_for_post(post_id)
.all(&db) .all(&db)
@ -1277,6 +1544,10 @@ pub(crate) async fn view_post_page(
children_posts.push(PostOverview { children_posts.push(PostOverview {
id: child_post.id, id: child_post.id,
thumbnail: child_post.thumbnail, thumbnail: child_post.thumbnail,
title: child_post.title,
description: child_post.description,
uploaded_at: child_post.uploaded_at,
media: child_post.media,
tags: Some( tags: Some(
get_tags_for_post(child_post.id) get_tags_for_post(child_post.id)
.all(&db) .all(&db)
@ -1371,6 +1642,7 @@ struct SubmitPostDetailsTemplate {
parent_post: Option<PostOverview>, parent_post: Option<PostOverview>,
sources: Vec<samey_post_source::Model>, sources: Vec<samey_post_source::Model>,
tags: Vec<samey_tag::Model>, tags: Vec<samey_tag::Model>,
tags_text: String,
can_edit: bool, can_edit: bool,
} }
@ -1395,14 +1667,14 @@ pub(crate) async fn submit_post_details(
} }
let title = match body.title.trim() { let title = match body.title.trim() {
title if title.is_empty() => None, "" => None,
title => Some(title.to_owned()), title => Some(title.to_owned()),
}; };
let description = match body.description.trim() { let description = match body.description.trim() {
description if description.is_empty() => None, "" => None,
description => Some(description.to_owned()), description => Some(description.to_owned()),
}; };
let parent_post = if let Some(parent_id) = body.parent_post.trim().parse().ok() { let parent_post = if let Ok(parent_id) = body.parent_post.trim().parse() {
match filter_posts_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref()) match filter_posts_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref())
.one(&db) .one(&db)
.await? .await?
@ -1410,6 +1682,10 @@ pub(crate) async fn submit_post_details(
Some(parent_post) => Some(PostOverview { Some(parent_post) => Some(PostOverview {
id: parent_id, id: parent_id,
thumbnail: parent_post.thumbnail, thumbnail: parent_post.thumbnail,
title: parent_post.title,
description: parent_post.description,
uploaded_at: parent_post.uploaded_at,
media: parent_post.media,
tags: Some( tags: Some(
get_tags_for_post(post_id) get_tags_for_post(post_id)
.all(&db) .all(&db)
@ -1497,17 +1773,31 @@ pub(crate) async fn submit_post_details(
upload_tags.sort_by(|a, b| a.name.cmp(&b.name)); upload_tags.sort_by(|a, b| a.name.cmp(&b.name));
upload_tags upload_tags
}; };
let mut tags_text = String::new();
for tag in &tags {
if !tags_text.is_empty() {
tags_text.push(' ');
}
tags_text.push_str(&tag.name);
}
let sources = SameyPostSource::find() let sources = SameyPostSource::find()
.filter(samey_post_source::Column::PostId.eq(post_id)) .filter(samey_post_source::Column::PostId.eq(post_id))
.all(&db) .all(&db)
.await?; .await?;
tokio::spawn(async move {
if let Err(err) = clean_dangling_tags(&db).await {
println!("Error when cleaning dangling tags - {}", err);
}
});
Ok(Html( Ok(Html(
SubmitPostDetailsTemplate { SubmitPostDetailsTemplate {
post, post,
sources, sources,
tags, tags,
tags_text,
parent_post, parent_post,
can_edit: true, can_edit: true,
} }
@ -1615,9 +1905,11 @@ pub(crate) async fn delete_post(
SameyPost::delete_by_id(post.id).exec(&db).await?; SameyPost::delete_by_id(post.id).exec(&db).await?;
tokio::spawn(async move { tokio::spawn(async move {
let base_path = files_dir.as_ref(); let _ = std::fs::remove_file(files_dir.join(post.media));
let _ = std::fs::remove_file(base_path.join(post.media)); let _ = std::fs::remove_file(files_dir.join(post.thumbnail));
let _ = std::fs::remove_file(base_path.join(post.thumbnail)); if let Err(err) = clean_dangling_tags(&db).await {
println!("Error when cleaning dangling tags - {}", err);
}
}); });
Ok(Redirect::to("/")) Ok(Redirect::to("/"))

View file

@ -1,5 +1,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/files/favicon.png" />
<script src="/static/htmx.js"></script> <script src="/static/htmx.js"></script>
<script defer src="/static/alpine.js"></script> <script defer src="/static/alpine.js"></script>
<link rel="stylesheet" href="/static/water.css" /> <link rel="stylesheet" href="/static/water.css" />

View file

@ -1,46 +1,55 @@
<form hx-put="/post_details/{{ post.id }}" hx-target="this" hx-swap="outerHTML"> <article id="post-details">
<div> <form hx-put="/post_details/{{ post.id }}" hx-target="#post-details" hx-swap="outerHTML">
<label>Tags</label> <div>
{% let tags_value = tags %} {% include "fragments/tags_input.html" %} <label>Tags</label>
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul> {% let tags_value = tags %} {% include "fragments/tags_input.html" %}
</div> <div
<div> hx-trigger="keyup[key=='Escape'] from:previous .tags"
<label>Title</label> hx-target="next .tags-autocomplete"
<input name="title" type="text" maxlength="100" placeholder="Title" value="{% if let Some(title) = post.title %}{{ title }}{% endif %}" /> hx-swap="innerHTML"
</div> hx-delete="/remove"
<div> hidden
<label>Description</label> ></div>
<textarea name="description" placeholder="Description in Markdown">{% if let Some(description) = post.description %}{{ description }}{% endif %}</textarea> <ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
</div> </div>
<div> <div>
<label>Is public post?</label> <label>Title</label>
<input name="is_public" type="checkbox" {% if post.is_public %}checked{% endif %} value="true" /> <input name="title" type="text" maxlength="100" placeholder="Title" value="{% if let Some(title) = post.title %}{{ title }}{% endif %}" />
</div> </div>
<div> <div>
<label>Rating</label> <label>Description</label>
<select name="rating"> <textarea name="description" placeholder="Description in Markdown">{% if let Some(description) = post.description %}{{ description }}{% endif %}</textarea>
<option value="u" {% if post.rating == "u" %}selected{% endif %}>Unrated</option> </div>
<option value="s" {% if post.rating == "s" %}selected{% endif %}>Safe</option> <div>
<option value="q" {% if post.rating == "q" %}selected{% endif %}>Questionable</option> <label>Is public post?</label>
<option value="e" {% if post.rating == "e" %}selected{% endif %}>Explicit</option> <input name="is_public" type="checkbox" {% if post.is_public %}checked{% endif %} value="true" />
</select> </div>
</div> <div>
<div> <label>Rating</label>
<label>Source(s)</label> <select name="rating">
<ul id="sources"> <option value="u" {% if post.rating == "u" %}selected{% endif %}>Unrated</option>
{% for source in sources %} <option value="s" {% if post.rating == "s" %}selected{% endif %}>Safe</option>
{% include "fragments/post_source.html" %} <option value="q" {% if post.rating == "q" %}selected{% endif %}>Questionable</option>
{% endfor %} <option value="e" {% if post.rating == "e" %}selected{% endif %}>Explicit</option>
</ul> </select>
<button hx-post="/post_source" hx-target="#sources" hx-swap="beforeend">+ Add source</button> </div>
</div> <div>
<div> <label>Source(s)</label>
<label>Parent post</label> <ul id="sources">
<input name="parent_post" type="text" pattern="[0-9]*" value="{% if let Some(parent_id) = post.parent_id %}{{ parent_id }}{% endif %}" placeholder="Post ID" /> {% for source in sources %}
</div> {% include "fragments/post_source.html" %}
<div> {% endfor %}
<button>Save changes</button> </ul>
<button hx-get="/post_details/{{ post.id }}">Cancel</button> <button hx-post="/post_source" hx-target="#sources" hx-swap="beforeend">+ Add source</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="/">Delete post</button> </div>
</div> <div>
</div> <label>Parent post</label>
<input name="parent_post" type="text" pattern="[0-9]*" value="{% if let Some(parent_id) = post.parent_id %}{{ parent_id }}{% endif %}" placeholder="Post ID" />
</div>
<div>
<button>Save changes</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="/">Delete post</button>
</div>
</form>
</article>

View file

@ -1,6 +1,7 @@
<video <video
id="media" id="media"
src="/files/{{ post.media }}" src="/files/{{ post.media }}"
controls="controls" controls="true"
:style="{ width: '100%', height: '100%', 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height }" style="width: 100%; height: 100%"
:style="{ 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height }"
></video> ></video>

View file

@ -1,24 +1,23 @@
<article id="post-details" hx-target="this" hx-swap="outerHTML"> <article id="post-details">
<h2> <h2>
{% if let Some(title) = post.title %}{{ title }}{% else %}Details{% {% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %}
endif %}
</h2> </h2>
{% if let Some(description) = post.description %} {% if let Some(description) = post.description %}
<div id="description">{{ description | markdown }}</div> <div id="description">{{ description | markdown }}</div>
{% endif %} {% endif %}
<table> <table>
{% if can_edit %}
<tr> <tr>
<th>Is public post?</th> <th>Is public post?</th>
<td> <td>{% if post.is_public %}Yes{% else %}No{% endif %}</td>
{% if post.is_public %}Yes{% else %}No{% endif %}
</td>
</tr> </tr>
{% endif %}
<tr> <tr>
<th>Rating</th> <th>Rating</th>
<td> <td>
{% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %} Safe {% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %}
{% when "q" %} Questionable {% when "e" %} Explicit {% else %} Unknown {% Safe {% when "q" %} Questionable {% when "e" %} Explicit {% else %}
endmatch %} Unknown {% endmatch %}
</td> </td>
</tr> </tr>
<tr> <tr>
@ -54,6 +53,12 @@
</tr> </tr>
</table> </table>
{% if can_edit %} {% if can_edit %}
<button hx-get="/post_details/{{ post.id }}/edit">Edit post</button> <button
hx-get="/post_details/{{ post.id }}/edit"
hx-target="#post-details"
hx-swap="outerHTML"
>
Edit post
</button>
{% endif %} {% endif %}
</div> </article>

View file

@ -0,0 +1,11 @@
<h1>
{% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %}
</h1>
{% match post.media_type.as_ref() %}{% when "image" %}
<img src="{{ base_url }}/files/{{ post.media }}" />
{% when "video" %}
<video src="{{ base_url }}/files/{{ post.media }}" controls="true"></video>
{% else %}{% endmatch %}{% if let Some(description) = post.description %}
<h2>Description</h2>
<div>{{ description | markdown }}</div>
{% endif %}

View file

@ -1,4 +1,10 @@
{% for tag in tags %} {% if !tags.is_empty() %}
<li>
<button hx-delete="/remove" hx-target="closest ul" hx-swap="innerHTML">
(close)
</button>
</li>
{% endif %}{% for tag in tags %}
<li> <li>
<button <button
hx-post="/select_tag" hx-post="/select_tag"

View file

@ -18,10 +18,17 @@ parent_post %}
{% else %} {% else %}
<article id="parent-post" hx-swap-oob="outerHTML" hidden></article> <article id="parent-post" hx-swap-oob="outerHTML" hidden></article>
{% endif %} {% endif %}
<ul id="tags-list" hx-swap-oob="outerHTML"> <article id="tags-list" hx-swap-oob="outerHTML">
{% for tag in tags %} <h2>Tags</h2>
<li> {% if tags.is_empty() %}
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a> <p>No tags in post. Consider adding some!</p>
</li> {% else %}
{% endfor %} <ul>
</ul> {% for tag in tags %}
<li>
<a href="/posts?tags={{ tags_text.replace(' ', "+") }}+{{ tag.name }}">+</a> <a href="/posts?tags={{ tags_text.replace(' ', "+") }}+-{{ tag.name }}">-</a> <a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</article>

View file

@ -5,10 +5,11 @@
name="tags" name="tags"
placeholder="Tags" placeholder="Tags"
hx-post="/search_tags" hx-post="/search_tags"
hx-trigger="input changed" hx-trigger="input changed delay:500ms"
hx-target="next .tags-autocomplete" hx-target="next .tags-autocomplete"
hx-vals="js:{selection_end: event.target.selectionEnd}" hx-swap="innerHTML"
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);" hx-vals="js:{selection_end: document.querySelector('.tags').selectionEnd}"
hx-on::after-settle="document.querySelector('.tags').focus(); document.querySelector('.tags').setSelectionRange(-1, -1);"
value="{{ tags_value }}" value="{{ tags_value }}"
aria-autocomplete="list" aria-autocomplete="list"
aria-controls="search-autocomplete" aria-controls="search-autocomplete"

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Bad request</title>
{% include "fragments/common_headers.html" %}
</head>
<body>
<div><a href="/">&lt; To home</a></div>
<main>
<h1>Bad request</h1>
<p>The provided parameters are invalid: {{ error }}</p>
</main>
</body>
</html>

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Bulk edit tag - {{ application_name }}</title>
<meta property="og:site_name" content="{{ application_name }}" />
{% include "fragments/common_headers.html" %}
</head>
<body>
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
%}{% endif %}
<div><a href="/">&lt; To home</a></div>
<main>
<h1>Bulk edit tag</h1>
<article>
<h2>Select tag to edit</h2>
<form method="post" action="/bulk_edit_tag">
{% let tags_value = "" %} {% include "fragments/tags_input.html" %}
<div
hx-trigger="keyup[key=='Escape'] from:previous .tags"
hx-target="next .tags-autocomplete"
hx-swap="innerHTML"
hx-delete="/remove"
hidden
></div>
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
<input type="text" name="new_tag" placeholder="New tag" />
<button type="submit">Submit</button>
{% match message %}{% when BulkEditTagMessage::Success %}
<div>Success!</div>
{% when BulkEditTagMessage::Failure with (msg) %}
<div>Error: {{ msg }}</div>
{% when BulkEditTagMessage::None %}{% endmatch %}
</form>
</article>
</main>
</body>
</html>

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Forbidden</title>
{% include "fragments/common_headers.html" %}
</head>
<body>
<div><a href="/">&lt; To home</a></div>
<main>
<h1>Forbidden</h1>
<p>The requested action is not allowed.</p>
</main>
</body>
</html>

View file

@ -14,6 +14,13 @@
<h2>Search</h2> <h2>Search</h2>
<form method="get" action="/posts/1"> <form method="get" action="/posts/1">
{% let tags_value = "" %} {% include "fragments/tags_input.html" %} {% let tags_value = "" %} {% include "fragments/tags_input.html" %}
<div
hx-trigger="keyup[key=='Escape'] from:previous .tags"
hx-target="next .tags-autocomplete"
hx-swap="innerHTML"
hx-delete="/remove"
hidden
></div>
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul> <ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
<button type="submit">Search</button> <button type="submit">Search</button>
</form> </form>
@ -34,6 +41,9 @@
<a href="/create_pool">Create pool</a> <a href="/create_pool">Create pool</a>
</li> </li>
{% if user.is_admin %} {% if user.is_admin %}
<li>
<a href="/bulk_edit_tag">Bulk edit tag</a>
</li>
<li> <li>
<a href="/settings">Settings</a> <a href="/settings">Settings</a>
</li> </li>

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Internal server error</title>
{% include "fragments/common_headers.html" %}
</head>
<body>
<div><a href="/">&lt; To home</a></div>
<main>
<h1>Internal server error</h1>
<p>
Something went wrong! Ask your administrator to check the application
logs.
</p>
</main>
</body>
</html>

View file

@ -106,6 +106,16 @@
value="true" value="true"
/> />
</div> </div>
<div>
<button
hx-confirm="Are you sure that you want to delete this pool? This can't be undone!"
hx-delete="/pool/{{ pool.id }}"
hx-target="body"
hx-replace-url="/"
>
Delete pool
</button>
</div>
</article> </article>
{% endif %} {% endif %}
</body> </body>

View file

@ -9,10 +9,18 @@
{% if age_confirmation %}{% include "fragments/age_restricted_check.html" {% if age_confirmation %}{% include "fragments/age_restricted_check.html"
%}{% endif %} %}{% endif %}
<div><a href="/">&lt; To home</a></div> <div><a href="/">&lt; To home</a></div>
<div><a href="{% if let Some(tags_text) = tags_text %}/posts.xml?tags={{ tags_text.replace(' ', "+") }}{% else %}/posts.xml{% endif %}">RSS feed</a></div>
<article> <article>
<h2>Search</h2> <h2>Search</h2>
<form method="get" action="/posts"> <form method="get" action="/posts">
{% let tags_value = tags_text.clone().unwrap_or("".into()) %} {% include "fragments/tags_input.html" %} {% let tags_value = tags_text.clone().unwrap_or("".into()) %} {% include "fragments/tags_input.html" %}
<div
hx-trigger="keyup[key=='Escape'] from:previous .tags"
hx-target="next .tags-autocomplete"
hx-swap="innerHTML"
hx-delete="/remove"
hidden
></div>
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul> <ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
<button type="submit">Search</button> <button type="submit">Search</button>
</form> </form>

View file

@ -20,6 +20,14 @@
value="{{ application_name }}" value="{{ application_name }}"
/> />
</div> </div>
<div>
<label>Base URL</label>
<input name="base_url" type="text" value="{{ base_url }}" />
</div>
<div>
<label>Favicon post ID</label>
<input name="favicon_post_id" type="text" pattern="[0-9]*" />
</div>
<div> <div>
<label>Ask for age confirmation?</label> <label>Ask for age confirmation?</label>
<input <input

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Unauthorized</title>
{% include "fragments/common_headers.html" %}
</head>
<body>
<div><a href="/">&lt; To home</a></div>
<main>
<h1>Unauthorized</h1>
<p>The provided credentials are invalid.</p>
</main>
</body>
</html>

View file

@ -13,6 +13,13 @@
<h1>Upload media</h1> <h1>Upload media</h1>
<form method="post" action="/upload" enctype="multipart/form-data"> <form method="post" action="/upload" enctype="multipart/form-data">
{% let tags_value = "" %} {% include "fragments/tags_input.html" %} {% let tags_value = "" %} {% include "fragments/tags_input.html" %}
<div
hx-trigger="keyup[key=='Escape'] from:previous .tags"
hx-target="next .tags-autocomplete"
hx-swap="innerHTML"
hx-delete="/remove"
hidden
></div>
<ul class="reset tags-autocomplete" id="upload-autocomplete"></ul> <ul class="reset tags-autocomplete" id="upload-autocomplete"></ul>
<input <input
type="file" type="file"

View file

@ -29,26 +29,6 @@
{% if age_confirmation %}{% include "fragments/age_restricted_check.html" {% if age_confirmation %}{% include "fragments/age_restricted_check.html"
%}{% endif %} %}{% endif %}
<div><a href="{% if let Some(tags_text) = tags_text %}/posts/1?tags={{ tags_text.replace(' ', "+") }}{% else %}/posts/1{% endif %}">&lt; To posts</a></div> <div><a href="{% if let Some(tags_text) = tags_text %}/posts/1?tags={{ tags_text.replace(' ', "+") }}{% else %}/posts/1{% endif %}">&lt; To posts</a></div>
<article>
<h2>Search</h2>
<form method="get" action="/posts">
<input
class="tags"
type="text"
id="search-tags"
name="tags"
placeholder="Tags"
hx-post="/search_tags"
hx-trigger="input changed"
hx-target="next .tags-autocomplete"
hx-vals="js:{selection_end: event.target.selectionEnd}"
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
value="{% if let Some(tags_text) = tags_text %}{{ tags_text }}{% endif %}"
/>
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
<button type="submit">Search</button>
</form>
</article>
<article> <article>
<table> <table>
{% for item in pool_data %} {% for item in pool_data %}
@ -112,15 +92,19 @@
</ul> </ul>
</article> </article>
{% endif %} {% endif %}
<article> <article id="tags-list">
<h2>Tags</h2> <h2>Tags</h2>
{% if tags.is_empty() %} {% if tags.is_empty() %}
<p>No tags in post. Consider adding some!</p> <p>No tags in post. Consider adding some!</p>
{% else %} {% else %}
<ul id="tags-list"> <ul>
{% for tag in tags %} {% for tag in tags %}
<li> <li>
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a> {% if let Some(tags_text) = tags_text %}
<a href="/posts?tags={{ tags_text.replace(' ', "+") }}+{{ tag.name }}">+</a> <a href="/posts?tags={{ tags_text.replace(' ', "+") }}+-{{ tag.name }}">-</a> <a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
{% else %}
<a href="/posts?tags={{ tag.name }}">+</a> <a href="/posts?tags=-{{ tag.name }}">-</a> <a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>