Finishing up the MVP

This commit is contained in:
Bad Manners 2025-04-13 23:13:51 -03:00
parent 3619063e68
commit 7f533cc583
25 changed files with 535 additions and 291 deletions

28
Dockerfile Normal file
View file

@ -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" ]

View file

@ -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"
```

12
docker-compose.yml Normal file
View file

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

View file

@ -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<Self, SameyError> {
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,
})
}
}

View file

@ -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()
}

View file

@ -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<PathBuf>,
db: DatabaseConnection,
application_name: Arc<RwLock<String>>,
app_config: Arc<RwLock<AppConfig>>,
}
pub async fn create_user(
@ -85,18 +84,10 @@ pub async fn get_router(
db: DatabaseConnection,
files_dir: impl AsRef<Path>,
) -> Result<Router, SameyError> {
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))

View file

@ -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<SameyTag> {
.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<i32>,
pub(crate) next_post_id: Option<i32>,
}
#[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<Vec<PostPoolData>, 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::<PostInPool>().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,

View file

@ -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<User>,
}
pub(crate) async fn index(
State(AppState {
application_name, ..
}): State<AppState>,
State(AppState { app_config, .. }): State<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
State(AppState { app_config, .. }): State<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<impl IntoRes
#[template(path = "pages/upload.html")]
struct UploadPageTemplate {
application_name: String,
age_confirmation: bool,
}
pub(crate) async fn upload_page(
State(AppState {
application_name, ..
}): State<AppState>,
State(AppState { app_config, .. }): State<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<Vec<&'a str>>,
tags_text: Option<String>,
posts: Vec<PostOverview>,
@ -593,16 +616,15 @@ pub(crate) async fn posts(
}
pub(crate) async fn posts_page(
State(AppState {
db,
application_name,
..
}): State<AppState>,
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Query(query): Query<PostsQuery>,
Path(page): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
State(AppState { app_config, .. }): State<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<samey_pool::Model>,
page: u32,
page_count: u64,
}
pub(crate) async fn get_pools_page(
State(AppState {
db,
application_name,
..
}): State<AppState>,
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Path(page): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
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<PoolPost>,
can_edit: bool,
}
pub(crate) async fn view_pool(
State(AppState {
db,
application_name,
..
}): State<AppState>,
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
State(AppState { db, 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 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<bool>,
}
pub(crate) async fn update_settings(
State(AppState {
db,
application_name,
..
}): State<AppState>,
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Form(body): Form<UpdateSettingsForm>,
) -> Result<impl IntoResponse, SameyError> {
@ -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,6 +1164,18 @@ pub(crate) async fn update_settings(
});
}
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()
});
if !configs.is_empty() {
SameyConfig::insert_many(configs)
.on_conflict(
OnConflict::column(samey_config::Column::Key)
@ -1134,8 +1184,9 @@ pub(crate) async fn update_settings(
)
.exec(&db)
.await?;
}
Ok("")
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<PostPoolData>,
tags: Vec<samey_tag::Model>,
tags_text: Option<String>,
tags_post: String,
@ -1155,18 +1208,30 @@ struct ViewPostPageTemplate {
}
pub(crate) async fn view_post_page(
State(AppState {
db,
application_name,
..
}): State<AppState>,
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Query(query): Query<PostsQuery>,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,

5
static/alpine.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,10 @@ img {
max-width: none;
}
dialog::backdrop {
backdrop-filter: blur(32px);
}
div.center-item {
display: flex;
align-items: center;

View file

@ -0,0 +1,20 @@
<dialog
x-init="localStorage.ageVerified !== 'true' && $el.showModal()"
@cancel.prevent
@close-modal="$el.close()"
>
<header>Age restricted website</header>
<p>You must be 18+ to access this page.</p>
<p>
By confirming that you are at least 18 years old, your selection will be
saved to your browser to prevent this screen from appearing in the future.
</p>
<menu>
<button
@click="localStorage.ageVerified = 'true'; $event.target.dispatchEvent(new CustomEvent('close-modal', { bubbles: true }))"
>
I agree and am 18+ years old
</button>
<button @click="location.href = 'about:blank'">Take me back!</button>
</menu>
</dialog>

View file

@ -1,7 +1,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/static/htmx.js"></script>
<script defer src="/static/alpine.js"></script>
<link rel="stylesheet" href="/static/water.css" />
<link rel="stylesheet" href="/static/samey.css" />
<meta name="generator" content="Samey {{ env!("CARGO_PKG_VERSION") }}" />
<meta property="og:site_name" content="{{ application_name }}" />

View file

@ -1,7 +0,0 @@
<img
hx-get="/media/{{ post.id }}"
hx-swap="outerHTML"
id="media"
src="/files/{{ post.media }}"
style="width: {{ post.width }}px; height: {{ post.height }}px; aspect-ratio: {{ post.width }} / {{ post.height }}; cursor: zoom-out"
/>

View file

@ -1,7 +1,8 @@
<img
hx-get="/media/{{ post.id }}/full"
hx-swap="outerHTML"
id="media"
@click="maximized = !maximized"
@keyup.enter="maximized = !maximized"
tabindex="0"
src="/files/{{ post.media }}"
style="width: 100%; height: 100%; max-width: {{ post.width }}px; max-height: {{ post.height }}px; aspect-ratio: {{ post.width }} / {{ post.height }}; cursor: zoom-in"
:style="maximized ? { width: width + 'px', height: height + 'px', 'aspect-ratio': width + ' / ' + height, cursor: 'zoom-out' } : { width: '100%', height: '100%', 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height, cursor: 'zoom-in' }"
/>

View file

@ -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 }"
></video>

View file

@ -1,31 +1,32 @@
<div id="post-details" hx-target="this" hx-swap="outerHTML">
<div>
<label>Title</label>
{% if let Some(title) = post.title %}{{ title }}{% else %}<em>None</em>{%
<article id="post-details" hx-target="this" hx-swap="outerHTML">
<h2>
{% if let Some(title) = post.title %}{{ title }}{% else %}Details{%
endif %}
</div>
<div>
<label>Description</label>
{% if let Some(description) = post.description %}{{ description | markdown
}}{% else %}
<p><em>None</em></p>
</h2>
{% if let Some(description) = post.description %}
<div id="description">{{ description | markdown }}</div>
{% endif %}
</div>
<div>
<label>Is public?</label>
<table>
<tr>
<th>Is public post?</th>
<td>
{% if post.is_public %}Yes{% else %}No{% endif %}
</div>
<div>
<label>Rating</label>
</td>
</tr>
<tr>
<th>Rating</th>
<td>
{% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %} Safe
{% when "q" %} Questionable {% when "e" %} Explicit {% else %} Unknown {%
endmatch %}
</div>
<div>
<label>Source(s)</label>
</td>
</tr>
<tr>
<th>Source(s)</th>
<td>
{% if sources.is_empty() %}
<em>None</em>{% else %}
<ul>
<ul class="reset">
{% for source in sources %}
<li id="source-{{ source.id }}">
<a href="{{ source.url }}">{{ source.url }}</a>
@ -33,23 +34,25 @@
{% endfor %}
</ul>
{% endif %}
</div>
<div>
<label>Type</label>
{{ post.media_type | capitalize }}
</div>
<div>
<label>Width</label>
{{ post.width }}px
</div>
<div>
<label>Height</label>
{{ post.height }}px
</div>
<div>
<label>Upload date</label>
{{ post.uploaded_at }}
</div>
</td>
</tr>
<tr>
<th>Type</th>
<td>{{ post.media_type | capitalize }}</td>
</tr>
<tr>
<th>Width</th>
<td>{{ post.width }}px</td>
</tr>
<tr>
<th>Height</th>
<td>{{ post.height }}px</td>
</tr>
<tr>
<th>Upload date</th>
<td>{{ post.uploaded_at }}</td>
</tr>
</table>
{% if can_edit %}
<button hx-get="/post_details/{{ post.id }}/edit">Edit post</button>
{% endif %}

View file

@ -2,9 +2,12 @@
<html lang="en">
<head>
<title>Create pool - {{ 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>Create pool</h1>

View file

@ -2,9 +2,12 @@
<html lang="en">
<head>
<title>{{ 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 %}
<main>
<h1>{{ application_name }}</h1>
<article>

View file

@ -2,9 +2,12 @@
<html lang="en">
<head>
<title>Login - {{ 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>Login</h1>

View file

@ -2,6 +2,7 @@
<html lang="en">
<head>
<title>Pool - {{ pool.name }} - {{ application_name }}</title>
<meta property="og:site_name" content="{{ application_name }}" />
{% include "fragments/common_headers.html" %}
<script src="/static/sortable.js"></script>
<meta property="og:title" content="{{ pool.name }}" />
@ -47,16 +48,37 @@
{% endif %}
</head>
<body>
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
%}{% endif %}
<div><a href="/">&lt; To home</a></div>
<main>
<h1 id="pool-title">Pool - {{ pool.name }}</h1>
</main>
<article>
<h2>Posts</h2>
{% include "fragments/pool_posts.html" %}
</article>
</main>
{% if can_edit %}
<hr />
<article>
<h2>Add post to pool</h2>
<form
hx-post="/pool/{{ pool.id }}/post"
hx-target="#pool-posts"
hx-swap="outerHTML"
>
<input
id="add-post-input"
type="text"
name="post_id"
placeholder="Post ID"
pattern="[0-9]*"
/>
<button>Add post</button>
</form>
</article>
<article>
<h2>Pool settings</h2>
<div>
<label>Rename pool</label>
<input
@ -66,7 +88,6 @@
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-swap="none"
placeholder="Name"
pattern="[0-9]*"
value="{{ pool.name }}"
/>
</div>
@ -85,23 +106,6 @@
value="true"
/>
</div>
<form
hx-post="/pool/{{ pool.id }}/post"
hx-target="#pool-posts"
hx-swap="outerHTML"
>
<div>
<label>Add post</label>
<input
id="add-post-input"
type="text"
name="post_id"
placeholder="Post ID"
pattern="[0-9]*"
/>
<button>Add post</button>
</div>
</form>
</article>
{% endif %}
</body>

View file

@ -2,9 +2,12 @@
<html lang="en">
<head>
<title>Pools - {{ 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>Pools</h1>
@ -18,8 +21,10 @@
</li>
{% endfor %}
</ul>
<hr />
<div>
<ul>
<div class="flex"><span>Pages</span></div>
<ul class="reset flex">
{% for i in 1..=page_count %}
<li>
{% if i == page as u64 %}

View file

@ -2,9 +2,12 @@
<html lang="en">
<head>
<title>Posts - {{ 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>
<article>
<h2>Search</h2>
@ -49,8 +52,10 @@
{% endfor %}
</ul>
</div>
<hr>
<div>
<ul>
<div class="flex"><span>Pages</span></div>
<ul class="reset flex">
{% for i in 1..=page_count %}
<li>
{% if i == page as u64 %}

View file

@ -2,19 +2,38 @@
<html lang="en">
<head>
<title>Settings - {{ 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>Settings</h1>
<form hx-put="/settings" hx-swap="none">
<form method="post" action="/settings">
<div>
<label>Application name</label>
<input
name="application_name"
type="text"
value="{{ application_name }}"
/>
</div>
<div>
<label>Ask for age confirmation?</label>
<input
name="age_confirmation"
type="checkbox"
{%
if
age_confirmation
%}checked{%
endif
%}
value="true"
/>
</div>
<button>Save changes</button>
</form>
</main>

View file

@ -2,9 +2,12 @@
<html lang="en">
<head>
<title>Upload media - {{ 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>Upload media</h1>
@ -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"
/>
<button type="submit">Create post</button>
</form>

View file

@ -2,6 +2,7 @@
<html lang="en">
<head>
<title>Post #{{ post.id }} - {{ application_name }}</title>
<meta property="og:site_name" content="{{ application_name }}" />
{% include "fragments/common_headers.html" %}
{% if let Some(title) = post.title %}<meta property="og:title" content="{{ title }}"/>{% else %}<meta property="og:title" content="{{ tags_post }}" />{% endif %}
<meta property="og:url" content="/post/{{ post.id }}" />
@ -16,7 +17,7 @@
{% if let Some(description) = post.description %}<meta property="twitter:description" content="{{ description }}" />{% endif %}
<meta property="twitter:image" content="/files/{{ post.media }}" />
{% when "video" %}
<meta property="og:type" content="video.other">
<meta property="og:type" content="video.other" />
<meta property="og:video" content="/files/{{ post.media }}" />
<meta property="og:video:width" content="{{ post.width }}" />
<meta property="og:video:height" content="{{ post.height }}" />
@ -25,6 +26,8 @@
{% else %} {% endmatch %}
</head>
<body>
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
%}{% 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>
<article>
<h2>Search</h2>
@ -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
/>
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
<button type="submit">Search</button>
</form>
</article>
<article>
<h2>Tags</h2>
{% if tags.is_empty() %}
<p>No tags in post. Consider adding some!</p>
{% else %}
<ul id="tags-list">
{% for tag in tags %}
<li>
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
</li>
{% endfor %}
</ul>
<table>
{% for item in pool_data %}
<tr>
<td>
{% if let Some(previous_post_id) = item.previous_post_id %}
<a href="/post/{{ previous_post_id }}">&lt; Previous</a>
{% endif %}
</td>
<th>
<a href="/pool/{{ item.id }}">Pool: {{ item.name }}</a>
</th>
<td>
{% if let Some(next_post_id) = item.next_post_id %}
<a href="/post/{{ next_post_id }}">Next &gt;</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</article>
<main>
<h1>View post #{{ post.id }}</h1>
<div class="center-item">
<div class="center-item" x-data="{ maximized: false, width: {{ post.width }}, height: {{ post.height }} }">
{% match post.media_type.as_ref() %}{% when "image" %}{% include
"fragments/get_image_media.html" %}{% when "video" %}{% include
"fragments/get_video_media.html" %}{% else %}{% endmatch %}
</div>
</main>
<article>
<h2>Details</h2>
{% include "fragments/post_details.html" %}
</article>
{% if let Some(parent_post) = parent_post %}
<article id="parent-post">
<h2>Parent post</h2>
@ -105,5 +112,19 @@
</ul>
</article>
{% endif %}
<article>
<h2>Tags</h2>
{% if tags.is_empty() %}
<p>No tags in post. Consider adding some!</p>
{% else %}
<ul id="tags-list">
{% for tag in tags %}
<li>
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</article>
</body>
</html>