diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7592893 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM --platform=$BUILDPLATFORM rust:1.86.0-alpine3.21 AS builder +ENV PKGCONFIG_SYSROOTDIR=/ +RUN apk add --no-cache musl-dev perl build-base zig +RUN cargo install --locked cargo-zigbuild +RUN rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl +WORKDIR /app +COPY Cargo.toml Cargo.lock . +COPY migration ./migration +RUN mkdir src \ + && echo "fn main() {}" > src/main.rs \ + && cargo fetch \ + && cargo zigbuild --release --locked --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \ + && rm src/main.rs +COPY static ./static +COPY templates ./templates +COPY src ./src +RUN cargo zigbuild --release --locked --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl + +FROM --platform=$BUILDPLATFORM scratch AS binary +COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/samey /samey-linux-amd64 +COPY --from=builder /app/target/aarch64-unknown-linux-musl/release/samey /samey-linux-arm64 + +FROM alpine:3.21 AS runner +ARG TARGETOS +ARG TARGETARCH +RUN apk add --no-cache ffmpeg +COPY --from=binary /samey-${TARGETOS}-${TARGETARCH} /usr/bin/samey +ENTRYPOINT [ "samey" ] diff --git a/README.md b/README.md index 0780430..2499066 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,40 @@ # Samey -Sam's small image board. Currently a WIP. +Sam's small image board. + +## Status + +Still very much an early WIP. + +### Roadmap + +- [ ] Logging +- [ ] Improved error handling +- [ ] Caching +- [ ] Lossless compression +- [ ] Bulk edit tags/Fix tag capitalization +- [ ] User management +- [ ] Cleanup/fixup background tasks +- [ ] Text media +- [ ] Improve CSS +- [ ] Migrate to Cot...? ## Prerequisites - `ffmpeg` and `ffprobe` -## TODO +## Running -- [ ] Show pool(s) in post -- [ ] CSS -- [ ] Logging, better errors... - -### Post-0.1.0 roadmap - -- [ ] Bulk edit tags/Fix tag capitalization -- [ ] User management -- [ ] Text media -- [ ] Cleanup/fixup background tasks -- [ ] Migrate to Cot...? - -## Running in development +### Development ```bash bacon serve ``` + +### Docker Compose + +```bash +sqlite3 db.sqlite3 "VACUUM;" +docker compose up -d +docker compose run --rm samey add-admin-user -u admin -p "superSecretPassword" +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9fb11e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + samey: + image: badmanners/samey:latest + container_name: samey + restart: unless-stopped + ports: + - 8080:3000 + volumes: + - ./files:/files:rw + - type: bind + source: ./db.sqlite3 + target: /db.sqlite3 diff --git a/src/config.rs b/src/config.rs index 664dfaf..17ecefa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1 +1,40 @@ +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; + +use crate::{ + SameyError, + entities::{prelude::SameyConfig, samey_config}, +}; + pub(crate) const APPLICATION_NAME_KEY: &str = "APPLICATION_NAME"; +pub(crate) const AGE_CONFIRMATION_KEY: &str = "AGE_CONFIRMATION"; + +#[derive(Clone)] +pub(crate) struct AppConfig { + pub(crate) application_name: String, + pub(crate) age_confirmation: bool, +} + +impl AppConfig { + pub(crate) async fn new(db: &DatabaseConnection) -> Result { + let application_name = match SameyConfig::find() + .filter(samey_config::Column::Key.eq(APPLICATION_NAME_KEY)) + .one(db) + .await? + { + Some(row) => row.data.as_str().unwrap_or("Samey").to_owned(), + None => "Samey".to_owned(), + }; + let age_confirmation = match SameyConfig::find() + .filter(samey_config::Column::Key.eq(AGE_CONFIRMATION_KEY)) + .one(db) + .await? + { + Some(row) => row.data.as_bool().unwrap_or(false), + None => false, + }; + Ok(Self { + application_name, + age_confirmation, + }) + } +} diff --git a/src/error.rs b/src/error.rs index d142e3d..d3bdda0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,8 +1,13 @@ +use askama::Template; use axum::{ http::StatusCode, - response::{IntoResponse, Response}, + response::{Html, IntoResponse, Response}, }; +#[derive(askama::Template)] +#[template(path = "pages/not_found.html")] +struct NotFoundTemplate; + #[derive(Debug, thiserror::Error)] pub enum SameyError { #[error("Integer conversion error: {0}")] @@ -47,7 +52,15 @@ impl IntoResponse for SameyError { SameyError::Multipart(_) | SameyError::BadRequest(_) => { (StatusCode::BAD_REQUEST, "Invalid request").into_response() } - SameyError::NotFound => (StatusCode::NOT_FOUND, "Resource not found").into_response(), + SameyError::NotFound => ( + StatusCode::NOT_FOUND, + Html( + NotFoundTemplate {} + .render() + .expect("shouldn't fail to render NotFoundTemplate"), + ), + ) + .into_response(), SameyError::Authentication(_) => { (StatusCode::UNAUTHORIZED, "Not authorized").into_response() } diff --git a/src/lib.rs b/src/lib.rs index 77ad55d..8bc2b5f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,15 +21,14 @@ use axum::{ }; use axum_extra::routing::RouterExt; use axum_login::AuthManagerLayerBuilder; -use entities::{prelude::SameyConfig, samey_config}; use password_auth::generate_hash; -use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; +use sea_orm::{ActiveValue::Set, DatabaseConnection, EntityTrait}; use tokio::{fs, sync::RwLock}; use tower_http::services::ServeDir; use tower_sessions::SessionManagerLayer; use crate::auth::{Backend, SessionStorage}; -use crate::config::APPLICATION_NAME_KEY; +use crate::config::AppConfig; use crate::entities::{prelude::SameyUser, samey_user}; pub use crate::error::SameyError; use crate::views::*; @@ -61,7 +60,7 @@ fn assets_router() -> Router { pub(crate) struct AppState { files_dir: Arc, db: DatabaseConnection, - application_name: Arc>, + app_config: Arc>, } pub async fn create_user( @@ -85,18 +84,10 @@ pub async fn get_router( db: DatabaseConnection, files_dir: impl AsRef, ) -> Result { - let application_name = match SameyConfig::find() - .filter(samey_config::Column::Key.eq(APPLICATION_NAME_KEY)) - .one(&db) - .await? - { - Some(row) => row.data.as_str().unwrap_or("Samey").to_owned(), - None => "Samey".to_owned(), - }; let state = AppState { files_dir: Arc::new(files_dir.as_ref().to_owned()), db: db.clone(), - application_name: Arc::new(RwLock::new(application_name)), + app_config: Arc::new(RwLock::new(AppConfig::new(&db).await?)), }; fs::create_dir_all(files_dir.as_ref()).await?; @@ -125,8 +116,6 @@ pub async fn get_router( get(post_details).put(submit_post_details), ) .route_with_tsr("/post_source", post(add_post_source)) - .route_with_tsr("/media/{post_id}/full", get(get_full_media)) - .route_with_tsr("/media/{post_id}", get(get_media)) // Pool routes .route_with_tsr("/create_pool", get(create_pool_page)) .route_with_tsr("/pools", get(get_pools)) @@ -139,7 +128,7 @@ pub async fn get_router( .route_with_tsr("/pool/{pool_id}/sort", put(sort_pool)) .route_with_tsr("/pool_post/{pool_post_id}", delete(remove_pool_post)) // Settings routes - .route_with_tsr("/settings", get(settings).put(update_settings)) + .route_with_tsr("/settings", get(settings).post(update_settings)) // Search routes .route_with_tsr("/posts", get(posts)) .route_with_tsr("/posts/{page}", get(posts_page)) diff --git a/src/query.rs b/src/query.rs index 4784203..a4d51e0 100644 --- a/src/query.rs +++ b/src/query.rs @@ -2,16 +2,17 @@ use std::collections::HashSet; use migration::{Expr, Query}; use sea_orm::{ - ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr, QueryFilter, QueryOrder, - QuerySelect, RelationTrait, Select, SelectModel, Selector, + ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoSimpleExpr, + QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns, SelectModel, + Selector, }; use crate::{ - NEGATIVE_PREFIX, RATING_PREFIX, + NEGATIVE_PREFIX, RATING_PREFIX, SameyError, auth::User, entities::{ - prelude::{SameyPoolPost, SameyPost, SameyTag, SameyTagPost}, - samey_pool_post, samey_post, samey_tag, samey_tag_post, + prelude::{SameyPool, SameyPoolPost, SameyPost, SameyTag, SameyTagPost}, + samey_pool, samey_pool_post, samey_post, samey_tag, samey_tag_post, }, }; @@ -149,6 +150,64 @@ pub(crate) fn get_tags_for_post(post_id: i32) -> Select { .order_by_asc(samey_tag::Column::Name) } +#[derive(Debug)] +pub(crate) struct PostPoolData { + pub(crate) id: i32, + pub(crate) name: String, + pub(crate) previous_post_id: Option, + pub(crate) next_post_id: Option, +} + +#[derive(Debug, FromQueryResult)] +struct PostInPool { + id: i32, + name: String, + position: f32, +} + +pub(crate) async fn get_pool_data_for_post( + db: &DatabaseConnection, + post_id: i32, + user: Option<&User>, +) -> Result, SameyError> { + let mut query = SameyPool::find() + .inner_join(SameyPoolPost) + .select_column(samey_pool_post::Column::Position) + .filter(samey_pool_post::Column::PostId.eq(post_id)); + query = match user { + None => query.filter(samey_pool::Column::IsPublic.into_simple_expr()), + Some(user) if user.is_admin => query, + Some(user) => query.filter( + Condition::any() + .add(samey_pool::Column::IsPublic.into_simple_expr()) + .add(samey_pool::Column::UploaderId.eq(user.id)), + ), + }; + let pools = query.into_model::().all(db).await?; + + let mut post_pool_datas = Vec::with_capacity(pools.len()); + for pool in pools.into_iter() { + let posts_in_pool = get_posts_in_pool(pool.id, user).all(db).await?; + if let Ok(index) = posts_in_pool.binary_search_by(|post| { + post.position + .partial_cmp(&pool.position) + .expect("position should never be NaN") + }) { + post_pool_datas.push(PostPoolData { + id: pool.id, + name: pool.name, + previous_post_id: index + .checked_sub(1) + .and_then(|idx| posts_in_pool.get(idx)) + .map(|post| post.id), + next_post_id: posts_in_pool.get(index + 1).map(|post| post.id), + }); + } + } + + Ok(post_pool_datas) +} + #[derive(Debug, FromQueryResult)] pub(crate) struct PoolPost { pub(crate) id: i32, diff --git a/src/views.rs b/src/views.rs index 4ef8e51..9c99371 100644 --- a/src/views.rs +++ b/src/views.rs @@ -3,6 +3,7 @@ use std::{ collections::{HashMap, HashSet}, fs::OpenOptions, io::{BufReader, Seek, Write}, + mem, num::NonZero, str::FromStr, }; @@ -28,7 +29,7 @@ use tokio::{task::spawn_blocking, try_join}; use crate::{ AppState, NEGATIVE_PREFIX, RATING_PREFIX, auth::{AuthSession, Credentials, User}, - config::APPLICATION_NAME_KEY, + config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY}, entities::{ prelude::{ SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag, @@ -39,8 +40,8 @@ use crate::{ }, error::SameyError, query::{ - PoolPost, PostOverview, filter_posts_by_user, get_posts_in_pool, get_tags_for_post, - search_posts, + PoolPost, PostOverview, PostPoolData, filter_posts_by_user, get_pool_data_for_post, + get_posts_in_pool, get_tags_for_post, search_posts, }, video::{generate_thumbnail, get_dimensions_for_video}, }; @@ -67,19 +68,22 @@ mod filters { #[template(path = "pages/index.html")] struct IndexTemplate { application_name: String, + age_confirmation: bool, user: Option, } pub(crate) async fn index( - State(AppState { - application_name, .. - }): State, + State(AppState { app_config, .. }): State, auth_session: AuthSession, ) -> Result { - let application_name = application_name.read().await.clone(); + 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( IndexTemplate { application_name, + age_confirmation, user: auth_session.user, } .render()?, @@ -92,21 +96,30 @@ pub(crate) async fn index( #[template(path = "pages/login.html")] struct LoginPageTemplate { application_name: String, + age_confirmation: bool, } pub(crate) async fn login_page( - State(AppState { - application_name, .. - }): State, + State(AppState { app_config, .. }): State, auth_session: AuthSession, ) -> Result { if auth_session.user.is_some() { return Ok(Redirect::to("/").into_response()); } - let application_name = application_name.read().await.clone(); + 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(LoginPageTemplate { application_name }.render()?).into_response()) + Ok(Html( + LoginPageTemplate { + application_name, + age_confirmation, + } + .render()?, + ) + .into_response()) } pub(crate) async fn login( @@ -140,21 +153,30 @@ pub(crate) async fn logout(mut auth_session: AuthSession) -> Result, + State(AppState { app_config, .. }): State, auth_session: AuthSession, ) -> Result { if auth_session.user.is_none() { return Err(SameyError::Forbidden); } - let application_name = application_name.read().await.clone(); + 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(UploadPageTemplate { application_name }.render()?).into_response()) + Ok(Html( + UploadPageTemplate { + application_name, + age_confirmation, + } + .render()?, + ) + .into_response()) } enum Format { @@ -572,6 +594,7 @@ pub(crate) async fn select_tag( #[template(path = "pages/posts.html")] struct PostsTemplate<'a> { application_name: String, + age_confirmation: bool, tags: Option>, tags_text: Option, posts: Vec, @@ -593,16 +616,15 @@ pub(crate) async fn posts( } pub(crate) async fn posts_page( - State(AppState { - db, - application_name, - .. - }): State, + State(AppState { db, app_config, .. }): State, auth_session: AuthSession, Query(query): Query, Path(page): Path, ) -> Result { - let application_name = application_name.read().await.clone(); + 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 tags = query .tags .as_ref() @@ -625,6 +647,7 @@ pub(crate) async fn posts_page( Ok(Html( PostsTemplate { application_name, + age_confirmation, tags_text: tags.as_ref().map(|tags| tags.iter().join(" ")), tags, posts, @@ -641,21 +664,30 @@ pub(crate) async fn posts_page( #[template(path = "pages/create_pool.html")] struct CreatePoolPageTemplate { application_name: String, + age_confirmation: bool, } pub(crate) async fn create_pool_page( - State(AppState { - application_name, .. - }): State, + State(AppState { app_config, .. }): State, auth_session: AuthSession, ) -> Result { if auth_session.user.is_none() { return Err(SameyError::Forbidden); } - let application_name = application_name.read().await.clone(); + 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(CreatePoolPageTemplate { application_name }.render()?).into_response()) + Ok(Html( + CreatePoolPageTemplate { + application_name, + age_confirmation, + } + .render()?, + ) + .into_response()) } pub(crate) async fn get_pools( @@ -669,21 +701,21 @@ pub(crate) async fn get_pools( #[template(path = "pages/pools.html")] struct GetPoolsTemplate { application_name: String, + age_confirmation: bool, pools: Vec, page: u32, page_count: u64, } pub(crate) async fn get_pools_page( - State(AppState { - db, - application_name, - .. - }): State, + State(AppState { db, app_config, .. }): State, auth_session: AuthSession, Path(page): Path, ) -> Result { - let application_name = application_name.read().await.clone(); + 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 query = match auth_session.user { None => SameyPool::find().filter(samey_pool::Column::IsPublic.into_simple_expr()), Some(user) if user.is_admin => SameyPool::find(), @@ -702,6 +734,7 @@ pub(crate) async fn get_pools_page( Ok(Html( GetPoolsTemplate { application_name, + age_confirmation, pools, page, page_count, @@ -741,21 +774,21 @@ pub(crate) async fn create_pool( #[template(path = "pages/pool.html")] struct ViewPoolTemplate { application_name: String, + age_confirmation: bool, pool: samey_pool::Model, posts: Vec, can_edit: bool, } pub(crate) async fn view_pool( - State(AppState { - db, - application_name, - .. - }): State, + State(AppState { db, app_config, .. }): State, auth_session: AuthSession, Path(pool_id): Path, ) -> Result { - let application_name = application_name.read().await.clone(); + 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 pool = SameyPool::find_by_id(pool_id) .one(&db) .await? @@ -777,6 +810,7 @@ pub(crate) async fn view_pool( Ok(Html( ViewPoolTemplate { application_name, + age_confirmation, pool, can_edit, posts, @@ -1063,21 +1097,21 @@ pub(crate) async fn sort_pool( #[template(path = "pages/settings.html")] struct SettingsTemplate { application_name: String, + age_confirmation: bool, } pub(crate) async fn settings( - State(AppState { - db, - application_name, - .. - }): State, + State(AppState { db, app_config, .. }): State, auth_session: AuthSession, ) -> Result { if auth_session.user.is_none_or(|user| !user.is_admin) { return Err(SameyError::Forbidden); } - let application_name = application_name.read().await.clone(); + 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 config = SameyConfig::find().all(&db).await?; @@ -1093,21 +1127,22 @@ pub(crate) async fn settings( .collect(); Ok(Html( - SettingsTemplate { application_name }.render_with_values(&values)?, + SettingsTemplate { + application_name, + age_confirmation, + } + .render_with_values(&values)?, )) } #[derive(Debug, Deserialize)] pub(crate) struct UpdateSettingsForm { application_name: String, + age_confirmation: Option, } pub(crate) async fn update_settings( - State(AppState { - db, - application_name, - .. - }): State, + State(AppState { db, app_config, .. }): State, auth_session: AuthSession, Form(body): Form, ) -> Result { @@ -1118,7 +1153,10 @@ pub(crate) async fn update_settings( let mut configs = vec![]; if !body.application_name.is_empty() { - *application_name.write().await = body.application_name.clone(); + let _ = mem::replace( + &mut app_config.write().await.application_name, + body.application_name.clone(), + ); configs.push(samey_config::ActiveModel { key: Set(APPLICATION_NAME_KEY.into()), data: Set(body.application_name.into()), @@ -1126,16 +1164,29 @@ pub(crate) async fn update_settings( }); } - SameyConfig::insert_many(configs) - .on_conflict( - OnConflict::column(samey_config::Column::Key) - .update_column(samey_config::Column::Data) - .to_owned(), - ) - .exec(&db) - .await?; + let age_confirmation = body.age_confirmation.is_some(); + let _ = mem::replace( + &mut app_config.write().await.age_confirmation, + age_confirmation, + ); + configs.push(samey_config::ActiveModel { + key: Set(AGE_CONFIRMATION_KEY.into()), + data: Set(age_confirmation.into()), + ..Default::default() + }); - Ok("") + if !configs.is_empty() { + SameyConfig::insert_many(configs) + .on_conflict( + OnConflict::column(samey_config::Column::Key) + .update_column(samey_config::Column::Data) + .to_owned(), + ) + .exec(&db) + .await?; + } + + Ok(Redirect::to("/")) } // Single post views @@ -1144,7 +1195,9 @@ pub(crate) async fn update_settings( #[template(path = "pages/view_post.html")] struct ViewPostPageTemplate { application_name: String, + age_confirmation: bool, post: samey_post::Model, + pool_data: Vec, tags: Vec, tags_text: Option, tags_post: String, @@ -1155,18 +1208,30 @@ struct ViewPostPageTemplate { } pub(crate) async fn view_post_page( - State(AppState { - db, - application_name, - .. - }): State, + State(AppState { db, app_config, .. }): State, auth_session: AuthSession, Query(query): Query, Path(post_id): Path, ) -> Result { - let application_name = application_name.read().await.clone(); + 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 post = SameyPost::find_by_id(post_id) + .one(&db) + .await? + .ok_or(SameyError::NotFound)?; + + let can_edit = match auth_session.user.as_ref() { + None => false, + Some(user) => user.is_admin || post.uploader_id == user.id, + }; + + if !post.is_public && !can_edit { + return Err(SameyError::NotFound); + } - let post_id = post_id; let tags = get_tags_for_post(post_id).all(&db).await?; let tags_post = tags.iter().map(|tag| &tag.name).join(" "); @@ -1175,11 +1240,6 @@ pub(crate) async fn view_post_page( .all(&db) .await?; - let post = SameyPost::find_by_id(post_id) - .one(&db) - .await? - .ok_or(SameyError::NotFound)?; - let parent_post = if let Some(parent_id) = post.parent_id { match filter_posts_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref()) .one(&db) @@ -1230,19 +1290,14 @@ pub(crate) async fn view_post_page( }); } - let can_edit = match auth_session.user { - None => false, - Some(user) => user.is_admin || post.uploader_id == user.id, - }; - - if !post.is_public && !can_edit { - return Err(SameyError::NotFound); - } + let pool_data = get_pool_data_for_post(&db, post_id, auth_session.user.as_ref()).await?; Ok(Html( ViewPostPageTemplate { application_name, + age_confirmation, post, + pool_data, tags, tags_text: query.tags, tags_post, @@ -1538,62 +1593,6 @@ pub(crate) async fn remove_field() -> impl IntoResponse { "" } -#[derive(Template)] -#[template(path = "fragments/get_image_media.html")] -struct GetMediaTemplate { - post: samey_post::Model, -} - -pub(crate) async fn get_media( - State(AppState { db, .. }): State, - auth_session: AuthSession, - Path(post_id): Path, -) -> Result { - let post = SameyPost::find_by_id(post_id) - .one(&db) - .await? - .ok_or(SameyError::NotFound)?; - - let can_edit = match auth_session.user { - None => false, - Some(user) => user.is_admin || post.uploader_id == user.id, - }; - - if !post.is_public && !can_edit { - return Err(SameyError::NotFound); - } - - Ok(Html(GetMediaTemplate { post }.render()?)) -} - -#[derive(Template)] -#[template(path = "fragments/get_full_image_media.html")] -struct GetFullMediaTemplate { - post: samey_post::Model, -} - -pub(crate) async fn get_full_media( - State(AppState { db, .. }): State, - auth_session: AuthSession, - Path(post_id): Path, -) -> Result { - let post = SameyPost::find_by_id(post_id) - .one(&db) - .await? - .ok_or(SameyError::NotFound)?; - - let can_edit = match auth_session.user { - None => false, - Some(user) => user.is_admin || post.uploader_id == user.id, - }; - - if !post.is_public && !can_edit { - return Err(SameyError::NotFound); - } - - Ok(Html(GetFullMediaTemplate { post }.render()?)) -} - pub(crate) async fn delete_post( State(AppState { db, files_dir, .. }): State, auth_session: AuthSession, diff --git a/static/alpine.js b/static/alpine.js new file mode 100644 index 0000000..2fdd6ec --- /dev/null +++ b/static/alpine.js @@ -0,0 +1,5 @@ +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + - diff --git a/templates/fragments/get_full_image_media.html b/templates/fragments/get_full_image_media.html deleted file mode 100644 index ee51384..0000000 --- a/templates/fragments/get_full_image_media.html +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/templates/fragments/get_image_media.html b/templates/fragments/get_image_media.html index 553df7e..ccea5d8 100644 --- a/templates/fragments/get_image_media.html +++ b/templates/fragments/get_image_media.html @@ -1,7 +1,8 @@ diff --git a/templates/fragments/get_video_media.html b/templates/fragments/get_video_media.html index 37cf898..7e130c0 100644 --- a/templates/fragments/get_video_media.html +++ b/templates/fragments/get_video_media.html @@ -2,5 +2,5 @@ id="media" src="/files/{{ post.media }}" controls="controls" - style="width: 100%; height: 100%; max-width: {{ post.width }}px; max-height: {{ post.height }}px; aspect-ratio: {{ post.width }} / {{ post.height }}" -/> + :style="{ width: '100%', height: '100%', 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height }" +> diff --git a/templates/fragments/post_details.html b/templates/fragments/post_details.html index 51d6410..1283837 100644 --- a/templates/fragments/post_details.html +++ b/templates/fragments/post_details.html @@ -1,55 +1,58 @@ -
-
- - {% if let Some(title) = post.title %}{{ title }}{% else %}None{% +
+

+ {% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %} -

-
- - {% if let Some(description) = post.description %}{{ description | markdown - }}{% else %} -

None

- {% endif %} -
-
- - {% if post.is_public %}Yes{% else %}No{% endif %} -
-
- - {% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %} Safe - {% when "q" %} Questionable {% when "e" %} Explicit {% else %} Unknown {% - endmatch %} -
-
- - {% if sources.is_empty() %} - None{% else %} - - {% endif %} -
-
- - {{ post.media_type | capitalize }} -
-
- - {{ post.width }}px -
-
- - {{ post.height }}px -
-
- - {{ post.uploaded_at }} -
+ + {% if let Some(description) = post.description %} +
{{ description | markdown }}
+ {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Is public post? + {% if post.is_public %}Yes{% else %}No{% endif %} +
Rating + {% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %} Safe + {% when "q" %} Questionable {% when "e" %} Explicit {% else %} Unknown {% + endmatch %} +
Source(s) + {% if sources.is_empty() %} + None{% else %} + + {% endif %} +
Type{{ post.media_type | capitalize }}
Width{{ post.width }}px
Height{{ post.height }}px
Upload date{{ post.uploaded_at }}
{% if can_edit %} {% endif %} diff --git a/templates/pages/create_pool.html b/templates/pages/create_pool.html index cfbc347..e76eecc 100644 --- a/templates/pages/create_pool.html +++ b/templates/pages/create_pool.html @@ -2,9 +2,12 @@ Create pool - {{ application_name }} + {% include "fragments/common_headers.html" %} + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %}

Create pool

diff --git a/templates/pages/index.html b/templates/pages/index.html index 2c15cf8..407bebc 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -2,9 +2,12 @@ {{ application_name }} + {% include "fragments/common_headers.html" %} + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %}

{{ application_name }}

diff --git a/templates/pages/login.html b/templates/pages/login.html index a31bc72..cd61f6c 100644 --- a/templates/pages/login.html +++ b/templates/pages/login.html @@ -2,9 +2,12 @@ Login - {{ application_name }} + {% include "fragments/common_headers.html" %} + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %}

Login

diff --git a/templates/pages/pool.html b/templates/pages/pool.html index 936a5c0..74f5ba5 100644 --- a/templates/pages/pool.html +++ b/templates/pages/pool.html @@ -2,6 +2,7 @@ Pool - {{ pool.name }} - {{ application_name }} + {% include "fragments/common_headers.html" %} @@ -47,16 +48,37 @@ {% endif %} + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %}

Pool - {{ pool.name }}

+
+

Posts

+ {% include "fragments/pool_posts.html" %} +
-
-

Posts

- {% include "fragments/pool_posts.html" %} -
{% if can_edit %} +
+

Add post to pool

+
+ + +
+
+
+

Pool settings

@@ -85,23 +106,6 @@ value="true" />
-
-
- - - -
-
{% endif %} diff --git a/templates/pages/pools.html b/templates/pages/pools.html index 77ffbaa..5ca81c3 100644 --- a/templates/pages/pools.html +++ b/templates/pages/pools.html @@ -2,9 +2,12 @@ Pools - {{ application_name }} + {% include "fragments/common_headers.html" %} + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %}

Pools

@@ -18,8 +21,10 @@ {% endfor %} +
-
    +
    Pages
    +
      {% for i in 1..=page_count %}
    • {% if i == page as u64 %} diff --git a/templates/pages/posts.html b/templates/pages/posts.html index e4a61a4..e89c4b8 100644 --- a/templates/pages/posts.html +++ b/templates/pages/posts.html @@ -2,9 +2,12 @@ Posts - {{ application_name }} + {% include "fragments/common_headers.html" %} + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %}

      Search

      @@ -49,8 +52,10 @@ {% endfor %}
+
-
    +
    Pages
    +
      {% for i in 1..=page_count %}
    • {% if i == page as u64 %} diff --git a/templates/pages/settings.html b/templates/pages/settings.html index c6c8d16..ff79c13 100644 --- a/templates/pages/settings.html +++ b/templates/pages/settings.html @@ -2,19 +2,38 @@ Settings - {{ application_name }} + {% include "fragments/common_headers.html" %} + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %}

      Settings

      -
      - - + +
      + + +
      +
      + + +
      diff --git a/templates/pages/upload.html b/templates/pages/upload.html index 149bbc1..f6533a6 100644 --- a/templates/pages/upload.html +++ b/templates/pages/upload.html @@ -2,9 +2,12 @@ Upload media - {{ application_name }} + {% include "fragments/common_headers.html" %} + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %}

      Upload media

      @@ -15,7 +18,7 @@ type="file" id="media-file" name="media-file" - accept=".jpg, .jpeg, .png, .webp, .gif, .mp4, .webm, .mkv, .mov" + accept=".jpg, .jpeg, .png, .webp, .gif, .bmp, .tiff, .mp4, .webm, .mkv, .mov" /> diff --git a/templates/pages/view_post.html b/templates/pages/view_post.html index ec446d7..af56aed 100644 --- a/templates/pages/view_post.html +++ b/templates/pages/view_post.html @@ -2,6 +2,7 @@ Post #{{ post.id }} - {{ application_name }} + {% include "fragments/common_headers.html" %} {% if let Some(title) = post.title %}{% else %}{% endif %} @@ -12,11 +13,11 @@ - {% if let Some(title) = post.title %}{% else %}{% endif %} + {% if let Some(title) = post.title %}{% else %}{% endif %} {% if let Some(description) = post.description %}{% endif %} {% when "video" %} - + @@ -25,6 +26,8 @@ {% else %} {% endmatch %} + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %}

      Search

      @@ -34,44 +37,48 @@ 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 %}" - autofocus />
        -

        Tags

        - {% if tags.is_empty() %} -

        No tags in post. Consider adding some!

        - {% else %} -
          - {% for tag in tags %} -
        • - {{ tag.name }} -
        • + + {% for item in pool_data %} + + + + + {% endfor %} - - {% endif %} +
          + {% if let Some(previous_post_id) = item.previous_post_id %} + < Previous + {% endif %} + + Pool: {{ item.name }} + + {% if let Some(next_post_id) = item.next_post_id %} + Next > + {% endif %} +

        View post #{{ post.id }}

        -
        +
        {% match post.media_type.as_ref() %}{% when "image" %}{% include "fragments/get_image_media.html" %}{% when "video" %}{% include "fragments/get_video_media.html" %}{% else %}{% endmatch %}
        -
        -

        Details

        - {% include "fragments/post_details.html" %} -
        + {% include "fragments/post_details.html" %} {% if let Some(parent_post) = parent_post %}

        Parent post

        @@ -105,5 +112,19 @@
      {% endif %} +
      +

      Tags

      + {% if tags.is_empty() %} +

      No tags in post. Consider adding some!

      + {% else %} + + {% endif %} +