Basic settings support

This commit is contained in:
Bad Manners 2025-04-11 23:50:28 -03:00
parent 239258e324
commit 4960527af3
10 changed files with 189 additions and 20 deletions

View file

@ -4,9 +4,9 @@ Sam's small image board. Currently a WIP.
## TODO ## TODO
- [ ] Config
- [ ] Video support - [ ] Video support
- [ ] Cleanup/fixup background tasks - [ ] Cleanup/fixup background tasks
- [ ] User management
- [ ] CSS - [ ] CSS
- [ ] Cleanup, CLI, env vars, logging, better errors... - [ ] Cleanup, CLI, env vars, logging, better errors...

1
src/config.rs Normal file
View file

@ -0,0 +1 @@
pub(crate) const APPLICATION_NAME_KEY: &str = "APPLICATION_NAME";

View file

@ -1,4 +1,5 @@
pub(crate) mod auth; pub(crate) mod auth;
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;
@ -13,20 +14,22 @@ use axum::{
routing::{delete, get, post, put}, routing::{delete, get, post, put},
}; };
use axum_login::AuthManagerLayerBuilder; use axum_login::AuthManagerLayerBuilder;
use entities::{prelude::SameyConfig, samey_config};
use password_auth::generate_hash; use password_auth::generate_hash;
use sea_orm::{ActiveValue::Set, DatabaseConnection, EntityTrait}; use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use tokio::fs; use tokio::{fs, sync::RwLock};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_sessions::SessionManagerLayer; use tower_sessions::SessionManagerLayer;
use crate::auth::{Backend, SessionStorage}; use crate::auth::{Backend, SessionStorage};
use crate::config::APPLICATION_NAME_KEY;
use crate::entities::{prelude::SameyUser, samey_user}; use crate::entities::{prelude::SameyUser, samey_user};
pub use crate::error::SameyError; pub use crate::error::SameyError;
use crate::views::{ use crate::views::{
add_post_source, add_post_to_pool, change_pool_visibility, create_pool, delete_post, add_post_source, add_post_to_pool, change_pool_visibility, create_pool, delete_post,
edit_post_details, get_full_media, get_media, get_pools, get_pools_page, index, login, logout, edit_post_details, get_full_media, get_media, get_pools, get_pools_page, index, login, logout,
post_details, posts, posts_page, remove_field, remove_pool_post, search_tags, select_tag, post_details, posts, posts_page, remove_field, remove_pool_post, search_tags, select_tag,
sort_pool, submit_post_details, upload, view_pool, view_post, settings, sort_pool, submit_post_details, update_settings, upload, view_pool, view_post,
}; };
pub(crate) const NEGATIVE_PREFIX: &str = "-"; pub(crate) const NEGATIVE_PREFIX: &str = "-";
@ -36,6 +39,7 @@ pub(crate) const RATING_PREFIX: &str = "rating:";
pub(crate) struct AppState { pub(crate) struct AppState {
files_dir: Arc<String>, files_dir: Arc<String>,
db: DatabaseConnection, db: DatabaseConnection,
application_name: Arc<RwLock<String>>,
} }
pub async fn create_user( pub async fn create_user(
@ -56,9 +60,18 @@ pub async fn create_user(
} }
pub async fn get_router(db: DatabaseConnection, files_dir: &str) -> Result<Router, SameyError> { pub async fn get_router(db: DatabaseConnection, files_dir: &str) -> 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 { let state = AppState {
files_dir: Arc::new(files_dir.into()), files_dir: Arc::new(files_dir.into()),
db: db.clone(), db: db.clone(),
application_name: Arc::new(RwLock::new(application_name)),
}; };
fs::create_dir_all(files_dir).await?; fs::create_dir_all(files_dir).await?;
@ -96,6 +109,8 @@ pub async fn get_router(db: DatabaseConnection, files_dir: &str) -> Result<Route
.route("/pool/{pool_id}/post", post(add_post_to_pool)) .route("/pool/{pool_id}/post", post(add_post_to_pool))
.route("/pool/{pool_id}/sort", put(sort_pool)) .route("/pool/{pool_id}/sort", put(sort_pool))
.route("/pool_post/{pool_post_id}", delete(remove_pool_post)) .route("/pool_post/{pool_post_id}", delete(remove_pool_post))
// Settings routes
.route("/settings", get(settings).put(update_settings))
// Search routes // Search routes
.route("/posts", get(posts)) .route("/posts", get(posts))
.route("/posts/{page}", get(posts_page)) .route("/posts/{page}", get(posts_page))

View file

@ -1,5 +1,6 @@
use std::{ use std::{
collections::HashSet, any::Any,
collections::{HashMap, HashSet},
fs::OpenOptions, fs::OpenOptions,
io::{BufReader, Seek, Write}, io::{BufReader, Seek, Write},
num::NonZero, num::NonZero,
@ -26,9 +27,14 @@ use tokio::task::spawn_blocking;
use crate::{ use crate::{
AppState, NEGATIVE_PREFIX, RATING_PREFIX, AppState, NEGATIVE_PREFIX, RATING_PREFIX,
auth::{AuthSession, Credentials, User}, auth::{AuthSession, Credentials, User},
config::APPLICATION_NAME_KEY,
entities::{ entities::{
prelude::{SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag, SameyTagPost}, prelude::{
samey_pool, samey_pool_post, samey_post, samey_post_source, samey_tag, samey_tag_post, SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag,
SameyTagPost,
},
samey_config, samey_pool, samey_pool_post, samey_post, samey_post_source, samey_tag,
samey_tag_post,
}, },
error::SameyError, error::SameyError,
query::{ query::{
@ -43,12 +49,20 @@ const MAX_THUMBNAIL_DIMENSION: u32 = 192;
#[derive(Template)] #[derive(Template)]
#[template(path = "index.html")] #[template(path = "index.html")]
struct IndexTemplate { struct IndexTemplate {
application_name: String,
user: Option<User>, user: Option<User>,
} }
pub(crate) async fn index(auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> { pub(crate) async fn index(
State(AppState {
application_name, ..
}): State<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
let application_name = application_name.read().await.clone();
Ok(Html( Ok(Html(
IndexTemplate { IndexTemplate {
application_name,
user: auth_session.user, user: auth_session.user,
} }
.render()?, .render()?,
@ -85,7 +99,7 @@ pub(crate) async fn logout(mut auth_session: AuthSession) -> Result<impl IntoRes
// Post upload view // Post upload view
pub(crate) async fn upload( pub(crate) async fn upload(
State(AppState { db, files_dir }): State<AppState>, State(AppState { db, files_dir, .. }): State<AppState>,
auth_session: AuthSession, auth_session: AuthSession,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
@ -370,6 +384,7 @@ pub(crate) async fn select_tag(
#[derive(Template)] #[derive(Template)]
#[template(path = "posts.html")] #[template(path = "posts.html")]
struct PostsTemplate<'a> { struct PostsTemplate<'a> {
application_name: String,
tags: Option<Vec<&'a str>>, tags: Option<Vec<&'a str>>,
tags_text: Option<String>, tags_text: Option<String>,
posts: Vec<PostOverview>, posts: Vec<PostOverview>,
@ -391,11 +406,16 @@ pub(crate) async fn posts(
} }
pub(crate) async fn posts_page( pub(crate) async fn posts_page(
State(AppState { db, .. }): State<AppState>, State(AppState {
db,
application_name,
..
}): State<AppState>,
auth_session: AuthSession, auth_session: AuthSession,
Query(query): Query<PostsQuery>, Query(query): Query<PostsQuery>,
Path(page): Path<u32>, Path(page): Path<u32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let application_name = application_name.read().await.clone();
let tags = query let tags = query
.tags .tags
.as_ref() .as_ref()
@ -417,6 +437,7 @@ pub(crate) async fn posts_page(
Ok(Html( Ok(Html(
PostsTemplate { PostsTemplate {
application_name,
tags_text: tags.as_ref().map(|tags| tags.iter().join(" ")), tags_text: tags.as_ref().map(|tags| tags.iter().join(" ")),
tags, tags,
posts, posts,
@ -439,16 +460,22 @@ pub(crate) async fn get_pools(
#[derive(Template)] #[derive(Template)]
#[template(path = "pools.html")] #[template(path = "pools.html")]
struct GetPoolsTemplate { struct GetPoolsTemplate {
application_name: String,
pools: Vec<samey_pool::Model>, pools: Vec<samey_pool::Model>,
page: u32, page: u32,
page_count: u64, page_count: u64,
} }
pub(crate) async fn get_pools_page( pub(crate) async fn get_pools_page(
State(AppState { db, .. }): State<AppState>, State(AppState {
db,
application_name,
..
}): State<AppState>,
auth_session: AuthSession, auth_session: AuthSession,
Path(page): Path<u32>, Path(page): Path<u32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let application_name = application_name.read().await.clone();
let query = match auth_session.user { let query = match auth_session.user {
None => SameyPool::find().filter(samey_pool::Column::IsPublic.into_simple_expr()), None => SameyPool::find().filter(samey_pool::Column::IsPublic.into_simple_expr()),
Some(user) if user.is_admin => SameyPool::find(), Some(user) if user.is_admin => SameyPool::find(),
@ -466,6 +493,7 @@ pub(crate) async fn get_pools_page(
Ok(Html( Ok(Html(
GetPoolsTemplate { GetPoolsTemplate {
application_name,
pools, pools,
page, page,
page_count, page_count,
@ -504,16 +532,22 @@ pub(crate) async fn create_pool(
#[derive(Template)] #[derive(Template)]
#[template(path = "pool.html")] #[template(path = "pool.html")]
struct ViewPoolTemplate { struct ViewPoolTemplate {
application_name: String,
pool: samey_pool::Model, pool: samey_pool::Model,
posts: Vec<PoolPost>, posts: Vec<PoolPost>,
can_edit: bool, can_edit: bool,
} }
pub(crate) async fn view_pool( pub(crate) async fn view_pool(
State(AppState { db, .. }): State<AppState>, State(AppState {
db,
application_name,
..
}): State<AppState>,
auth_session: AuthSession, auth_session: AuthSession,
Path(pool_id): Path<i32>, Path(pool_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let application_name = application_name.read().await.clone();
let pool = SameyPool::find_by_id(pool_id) let pool = SameyPool::find_by_id(pool_id)
.one(&db) .one(&db)
.await? .await?
@ -534,6 +568,7 @@ pub(crate) async fn view_pool(
Ok(Html( Ok(Html(
ViewPoolTemplate { ViewPoolTemplate {
application_name,
pool, pool,
can_edit, can_edit,
posts, posts,
@ -763,11 +798,93 @@ pub(crate) async fn sort_pool(
)) ))
} }
// Settings views
#[derive(Template)]
#[template(path = "settings.html")]
struct SettingsTemplate {
application_name: String,
}
pub(crate) async fn settings(
State(AppState {
db,
application_name,
..
}): 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 config = SameyConfig::find().all(&db).await?;
let values: HashMap<&str, Box<dyn Any>> = config
.iter()
.filter_map(|row| match row.key.as_str() {
key if key == APPLICATION_NAME_KEY => row
.data
.as_str()
.map::<(&str, Box<dyn Any>), _>(|data| (&row.key, Box::new(data.to_owned()))),
_ => None,
})
.collect();
Ok(Html(
SettingsTemplate { application_name }.render_with_values(&values)?,
))
}
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateSettingsForm {
application_name: String,
}
pub(crate) async fn update_settings(
State(AppState {
db,
application_name,
..
}): State<AppState>,
auth_session: AuthSession,
Form(body): Form<UpdateSettingsForm>,
) -> Result<impl IntoResponse, SameyError> {
if auth_session.user.is_none_or(|user| !user.is_admin) {
return Err(SameyError::Forbidden);
}
let mut configs = vec![];
if !body.application_name.is_empty() {
*application_name.write().await = body.application_name.clone();
configs.push(samey_config::ActiveModel {
key: Set(APPLICATION_NAME_KEY.into()),
data: Set(body.application_name.into()),
..Default::default()
});
}
SameyConfig::insert_many(configs)
.on_conflict(
OnConflict::column(samey_config::Column::Key)
.update_column(samey_config::Column::Data)
.to_owned(),
)
.exec(&db)
.await?;
Ok("")
}
// Single post views // Single post views
#[derive(Template)] #[derive(Template)]
#[template(path = "view_post.html")] #[template(path = "view_post.html")]
struct ViewPostTemplate { struct ViewPostTemplate {
application_name: String,
post: samey_post::Model, post: samey_post::Model,
tags: Vec<samey_tag::Model>, tags: Vec<samey_tag::Model>,
tags_text: String, tags_text: String,
@ -778,10 +895,16 @@ struct ViewPostTemplate {
} }
pub(crate) async fn view_post( pub(crate) async fn view_post(
State(AppState { db, .. }): State<AppState>, State(AppState {
db,
application_name,
..
}): State<AppState>,
auth_session: AuthSession, auth_session: AuthSession,
Path(post_id): Path<i32>, Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let application_name = application_name.read().await.clone();
let post_id = post_id; let post_id = post_id;
let tags = get_tags_for_post(post_id).all(&db).await?; let tags = get_tags_for_post(post_id).all(&db).await?;
let tags_text = tags.iter().map(|tag| &tag.name).join(" "); let tags_text = tags.iter().map(|tag| &tag.name).join(" ");
@ -851,6 +974,7 @@ pub(crate) async fn view_post(
Ok(Html( Ok(Html(
ViewPostTemplate { ViewPostTemplate {
application_name,
post, post,
tags, tags,
tags_text, tags_text,
@ -1195,7 +1319,7 @@ pub(crate) async fn get_full_media(
} }
pub(crate) async fn delete_post( pub(crate) async fn delete_post(
State(AppState { db, files_dir }): State<AppState>, State(AppState { db, files_dir, .. }): State<AppState>,
auth_session: AuthSession, auth_session: AuthSession,
Path(post_id): Path<i32>, Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {

View file

@ -4,11 +4,12 @@
<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" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<title>Samey</title> <title>{{ application_name }}</title>
<meta name="generator" content="Samey {{ env!("CARGO_PKG_VERSION") }}" />
</head> </head>
<body> <body>
<main> <main>
<h1>Samey</h1> <h1>{{ application_name }}</h1>
<article> <article>
<h2>Search</h2> <h2>Search</h2>
<form method="get" action="/posts/1"> <form method="get" action="/posts/1">

View file

@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src=" https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js "></script> <script src=" https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js "></script>
<title>Pool - {{ pool.name }} - Samey</title> <title>Pool - {{ pool.name }} - {{ application_name }}</title>
<meta name="generator" content="Samey {{ env!("CARGO_PKG_VERSION") }}" />
<meta property="og:title" content="{{ pool.name }}" /> <meta property="og:title" content="{{ pool.name }}" />
<meta property="og:url" content="/pool/{{ pool.id }}" /> <meta property="og:url" content="/pool/{{ pool.id }}" />
<meta property="twitter:title" content="{{ pool.name }}" /> <meta property="twitter:title" content="{{ pool.name }}" />

View file

@ -4,7 +4,8 @@
<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" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<title>Pools - Samey</title> <title>Pools - {{ application_name }}</title>
<meta name="generator" content="Samey {{ env!("CARGO_PKG_VERSION") }}" />
</head> </head>
<body> <body>
<main> <main>

View file

@ -4,7 +4,8 @@
<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" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<title>Posts - Samey</title> <title>Posts - {{ application_name }}</title>
<meta name="generator" content="Samey {{ env!("CARGO_PKG_VERSION") }}" />
</head> </head>
<body> <body>
<article> <article>

24
templates/settings.html Normal file
View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<title>Settings - {{ application_name }}</title>
<meta name="generator" content="Samey {{ env!("CARGO_PKG_VERSION") }}" />
</head>
<body>
<main>
<h1>Settings</h1>
<form hx-put="/settings" hx-swap="none">
<label>Application name</label>
<input
name="application_name"
type="text"
value="{{ application_name }}"
/>
<button>Submit</button>
</form>
</main>
</body>
</html>

View file

@ -4,7 +4,8 @@
<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" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<title>Post #{{ post.id }} - Samey</title> <title>Post #{{ post.id }} - {{ application_name }}</title>
<meta name="generator" content="Samey {{ env!("CARGO_PKG_VERSION") }}" />
<meta <meta
property="og:title" property="og:title"
content="{% if let Some(title) = post.title %}{{ title }}{% endif %}" content="{% if let Some(title) = post.title %}{{ title }}{% endif %}"