Delete pool

This commit is contained in:
Bad Manners 2025-04-20 11:46:12 -03:00
parent 7553dd31dc
commit 261623960e
7 changed files with 100 additions and 26 deletions

View file

@ -19,21 +19,21 @@ Still very much an early WIP.
### Roadmap ### Roadmap
- [ ] Delete pools
- [ ] Logging and improved error handling - [ ] Logging and improved error handling
- [ ] Lossless compression - [ ] Lossless compression
- [ ] Caching - [ ] Caching
- [ ] Text media - [ ] Text media
- [ ] Improve CSS - [ ] Improve CSS
- [ ] Garbage collection background tasks (tags, pools) - [ ] Background tasks for garbage collection (dangling tags)
- [ ] User management - [ ] User management
- [ ] Testing
- [ ] Migrate to Cot...? - [ ] Migrate to Cot...?
## Running ## Running
### Prerequisites ### Dependencies
- `ffmpeg` and `ffprobe` - `ffmpeg` (with `ffprobe`)
### Development ### Development

View file

@ -8,32 +8,46 @@ use axum::{
#[template(path = "pages/not_found.html")] #[template(path = "pages/not_found.html")]
struct NotFoundTemplate; struct NotFoundTemplate;
/// Errors from Samey.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum SameyError { pub enum SameyError {
/// Integer conversion error.
#[error("Integer conversion error: {0}")] #[error("Integer conversion error: {0}")]
IntConversion(#[from] std::num::TryFromIntError), IntConversion(#[from] std::num::TryFromIntError),
/// Integer parsing error.
#[error("Integer parsing error: {0}")] #[error("Integer parsing error: {0}")]
IntParse(#[from] std::num::ParseIntError), IntParse(#[from] std::num::ParseIntError),
/// IO error.
#[error("IO error: {0}")] #[error("IO error: {0}")]
IO(#[from] std::io::Error), IO(#[from] std::io::Error),
/// Task error.
#[error("Task error: {0}")] #[error("Task error: {0}")]
Join(#[from] tokio::task::JoinError), Join(#[from] tokio::task::JoinError),
/// Template render error.
#[error("Template render error: {0}")] #[error("Template render error: {0}")]
Render(#[from] askama::Error), Render(#[from] askama::Error),
/// Database error.
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] sea_orm::error::DbErr), Database(#[from] sea_orm::error::DbErr),
/// File streaming error.
#[error("File streaming error: {0}")] #[error("File streaming error: {0}")]
Multipart(#[from] axum::extract::multipart::MultipartError), Multipart(#[from] axum::extract::multipart::MultipartError),
/// Image error.
#[error("Image error: {0}")] #[error("Image error: {0}")]
Image(#[from] image::ImageError), Image(#[from] image::ImageError),
#[error("Not found")] /// Authentication error.
NotFound,
#[error("Authentication error: {0}")] #[error("Authentication error: {0}")]
Authentication(String), Authentication(String),
/// Not found.
#[error("Not found")]
NotFound,
/// Not allowed.
#[error("Not allowed")] #[error("Not allowed")]
Forbidden, Forbidden,
/// Bad request.
#[error("Bad request: {0}")] #[error("Bad request: {0}")]
BadRequest(String), BadRequest(String),
/// Custom internal error.
#[error("Internal error: {0}")] #[error("Internal error: {0}")]
Other(String), Other(String),
} }

View file

@ -1,3 +1,5 @@
//! Sam's small image board.
pub(crate) mod auth; pub(crate) mod auth;
pub(crate) mod config; pub(crate) mod config;
pub(crate) mod entities; pub(crate) mod entities;
@ -63,14 +65,26 @@ pub(crate) struct AppState {
app_config: Arc<RwLock<AppConfig>>, app_config: Arc<RwLock<AppConfig>>,
} }
/// Helper function to create a single user.
///
/// You can specify if they must be an admin user via the `is_admin` flag.
///
/// ```
/// use samey::create_user;
///
/// # async fn _main() {
/// let db = sea_orm::Database::connect("sqlite:db.sqlite3?mode=rwc").await.unwrap();
/// create_user(db, "admin", "secretPassword", true).await.expect("Unable to add admin user");
/// # }
/// ```
pub async fn create_user( pub async fn create_user(
db: DatabaseConnection, db: DatabaseConnection,
username: String, username: &str,
password: String, password: &str,
is_admin: bool, is_admin: bool,
) -> Result<(), SameyError> { ) -> Result<(), SameyError> {
SameyUser::insert(samey_user::ActiveModel { SameyUser::insert(samey_user::ActiveModel {
username: Set(username), username: Set(username.into()),
password: Set(generate_hash(password)), password: Set(generate_hash(password)),
is_admin: Set(is_admin), is_admin: Set(is_admin),
..Default::default() ..Default::default()
@ -80,6 +94,18 @@ pub async fn create_user(
Ok(()) Ok(())
} }
/// Creates an Axum router for a Samey application.
///
/// ```
/// use samey::get_router;
///
/// # async fn _main() {
/// let db = sea_orm::Database::connect("sqlite:db.sqlite3?mode=rwc").await.unwrap();
/// let app = get_router(db, "files").await.unwrap();
/// let listener = tokio::net::TcpListener::bind(("0.0.0.0", 3000)).await.unwrap();
/// axum::serve(listener, app).await.unwrap();
/// # }
/// ```
pub async fn get_router( pub async fn get_router(
db: DatabaseConnection, db: DatabaseConnection,
files_dir: impl AsRef<Path>, files_dir: impl AsRef<Path>,
@ -123,7 +149,7 @@ pub async fn get_router(
.route_with_tsr("/pools", get(get_pools)) .route_with_tsr("/pools", get(get_pools))
.route_with_tsr("/pools/{page}", get(get_pools_page)) .route_with_tsr("/pools/{page}", get(get_pools_page))
.route_with_tsr("/pool", post(create_pool)) .route_with_tsr("/pool", post(create_pool))
.route_with_tsr("/pool/{pool_id}", get(view_pool)) .route_with_tsr("/pool/{pool_id}", get(view_pool).delete(delete_pool))
.route_with_tsr("/pool/{pool_id}/name", put(change_pool_name)) .route_with_tsr("/pool/{pool_id}/name", put(change_pool_name))
.route_with_tsr("/pool/{pool_id}/public", put(change_pool_visibility)) .route_with_tsr("/pool/{pool_id}/public", put(change_pool_visibility))
.route_with_tsr("/pool/{pool_id}/post", post(add_post_to_pool)) .route_with_tsr("/pool/{pool_id}/post", post(add_post_to_pool))

View file

@ -64,7 +64,7 @@ async fn main() {
} }
Commands::AddAdminUser { username, password } => { Commands::AddAdminUser { username, password } => {
create_user(db, username, password, true) create_user(db, &username, &password, true)
.await .await
.expect("Unable to add admin user"); .expect("Unable to add admin user");
} }

View file

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

View file

@ -357,6 +357,7 @@ pub(crate) async fn upload(
.read(true) .read(true)
.write(true) .write(true)
.create(true) .create(true)
.truncate(true)
.open(&file_path)?; .open(&file_path)?;
while let Some(chunk) = field.chunk().await? { while let Some(chunk) = field.chunk().await? {
file.write_all(&chunk)?; file.write_all(&chunk)?;
@ -407,6 +408,7 @@ pub(crate) async fn upload(
.read(true) .read(true)
.write(true) .write(true)
.create(true) .create(true)
.truncate(true)
.open(&file_path)?; .open(&file_path)?;
while let Some(chunk) = field.chunk().await? { while let Some(chunk) = field.chunk().await? {
file.write_all(&chunk)?; file.write_all(&chunk)?;
@ -528,13 +530,13 @@ pub(crate) async fn search_tags(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
Form(body): Form<SearchTagsForm>, Form(body): Form<SearchTagsForm>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let tags = match body.tags[..body.selection_end].split(' ').last() { let tags = match body.tags[..body.selection_end].split(' ').next_back() {
Some(mut tag) => { Some(mut tag) => {
tag = tag.trim(); tag = tag.trim();
if tag.is_empty() { if tag.is_empty() {
vec![] vec![]
} else if tag.starts_with(NEGATIVE_PREFIX) { } else if let Some(stripped_tag) = tag.strip_prefix(NEGATIVE_PREFIX) {
if tag[NEGATIVE_PREFIX.len()..].starts_with(RATING_PREFIX) { if stripped_tag.starts_with(RATING_PREFIX) {
[ [
format!("{}u", RATING_PREFIX), format!("{}u", RATING_PREFIX),
format!("{}s", RATING_PREFIX), format!("{}s", RATING_PREFIX),
@ -542,7 +544,7 @@ pub(crate) async fn search_tags(
format!("{}e", RATING_PREFIX), format!("{}e", RATING_PREFIX),
] ]
.into_iter() .into_iter()
.filter(|t| t.starts_with(&tag[NEGATIVE_PREFIX.len()..])) .filter(|t| t.starts_with(stripped_tag))
.map(|tag| SearchTag { .map(|tag| SearchTag {
value: format!("-{}", &tag), value: format!("-{}", &tag),
name: tag, name: tag,
@ -552,7 +554,7 @@ pub(crate) async fn search_tags(
SameyTag::find() SameyTag::find()
.filter(Expr::cust_with_expr( .filter(Expr::cust_with_expr(
"LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')", "LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')",
tag[NEGATIVE_PREFIX.len()..].to_lowercase(), stripped_tag.to_lowercase(),
)) ))
.limit(10) .limit(10)
.all(&db) .all(&db)
@ -1115,12 +1117,10 @@ pub(crate) async fn sort_pool(
}; };
let max_index = if body.new_index == posts.len().saturating_sub(1) { let max_index = if body.new_index == posts.len().saturating_sub(1) {
None None
} else if body.new_index < body.old_index {
Some(body.new_index)
} else { } else {
if body.new_index < body.old_index { Some(body.new_index + 1)
Some(body.new_index)
} else {
Some(body.new_index + 1)
}
}; };
let min = min_index.map(|index| posts[index].position).unwrap_or(0.0); let min = min_index.map(|index| posts[index].position).unwrap_or(0.0);
let max = max_index let max = max_index
@ -1148,6 +1148,30 @@ pub(crate) async fn sort_pool(
)) ))
} }
pub(crate) async fn delete_pool(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
let pool = SameyPool::find_by_id(pool_id)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
let can_edit = match auth_session.user.as_ref() {
None => false,
Some(user) => user.is_admin || pool.uploader_id == user.id,
};
if !can_edit {
return Err(SameyError::Forbidden);
}
SameyPool::delete_by_id(pool_id).exec(&db).await?;
Ok(Redirect::to("/"))
}
// Bulk edit tag views // Bulk edit tag views
enum BulkEditTagMessage { enum BulkEditTagMessage {
@ -1629,14 +1653,14 @@ pub(crate) async fn submit_post_details(
} }
let title = match body.title.trim() { let title = match body.title.trim() {
title if title.is_empty() => None, "" => None,
title => Some(title.to_owned()), title => Some(title.to_owned()),
}; };
let description = match body.description.trim() { let description = match body.description.trim() {
description if description.is_empty() => None, "" => None,
description => Some(description.to_owned()), description => Some(description.to_owned()),
}; };
let parent_post = if let Some(parent_id) = body.parent_post.trim().parse().ok() { let parent_post = if let Ok(parent_id) = body.parent_post.trim().parse() {
match filter_posts_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref()) match filter_posts_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref())
.one(&db) .one(&db)
.await? .await?

View file

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