Better error pages and clean up dangling tags

This commit is contained in:
Bad Manners 2025-04-21 13:46:35 -03:00
parent 4c1a8a9489
commit e679d167fc
10 changed files with 189 additions and 50 deletions

View file

@ -13,21 +13,15 @@ Still very much an early WIP.
- Post pools. - Post pools.
- RSS feeds. - RSS feeds.
### Known issues ### Possible roadmap
- No way to close tag autocompletion on mobile.
### Roadmap
- [ ] Logging and improved error handling
- [ ] Lossless compression
- [ ] Post selection
- [ ] Caching - [ ] Caching
- [ ] Text media - [ ] Text media
- [ ] Improve CSS - [ ] Improve CSS
- [ ] Background tasks for garbage collection (dangling tags)
- [ ] User management - [ ] User management
- [ ] Display thumbnails on post selection
- [ ] Testing - [ ] Testing
- [ ] Lossless compression
- [ ] Migrate to Cot...? - [ ] Migrate to Cot...?
## Running ## Running

View file

@ -221,3 +221,9 @@ impl ExpiredDeletion for SessionStorage {
Ok(()) Ok(())
} }
} }
impl From<axum_login::Error<Backend>> for SameyError {
fn from(value: axum_login::Error<Backend>) -> Self {
value.into()
}
}

View file

@ -4,10 +4,28 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
#[derive(askama::Template)]
#[template(path = "pages/bad_request.html")]
struct BadRequestTemplate<'a> {
error: &'a str,
}
#[derive(askama::Template)]
#[template(path = "pages/unauthorized.html")]
struct UnauthorizedTemplate;
#[derive(askama::Template)]
#[template(path = "pages/forbidden.html")]
struct ForbiddenTemplate;
#[derive(askama::Template)] #[derive(askama::Template)]
#[template(path = "pages/not_found.html")] #[template(path = "pages/not_found.html")]
struct NotFoundTemplate; struct NotFoundTemplate;
#[derive(askama::Template)]
#[template(path = "pages/internal_server_error.html")]
struct InternalServerErrorTemplate;
/// Errors from Samey. /// Errors from Samey.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum SameyError { pub enum SameyError {
@ -54,7 +72,6 @@ pub enum SameyError {
impl IntoResponse for SameyError { impl IntoResponse for SameyError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
println!("Server error - {}", &self);
match &self { match &self {
SameyError::IntConversion(_) SameyError::IntConversion(_)
| SameyError::IntParse(_) | SameyError::IntParse(_)
@ -64,11 +81,37 @@ impl IntoResponse for SameyError {
| SameyError::Database(_) | SameyError::Database(_)
| SameyError::Image(_) | SameyError::Image(_)
| SameyError::Other(_) => { | SameyError::Other(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong!").into_response() println!("Internal server error - {:?}", &self);
} (
SameyError::Multipart(_) | SameyError::BadRequest(_) => { StatusCode::INTERNAL_SERVER_ERROR,
(StatusCode::BAD_REQUEST, "Invalid request").into_response() Html(
InternalServerErrorTemplate {}
.render()
.expect("shouldn't fail to render InternalServerErrorTemplate"),
),
)
.into_response()
} }
SameyError::Multipart(error) => (
StatusCode::BAD_REQUEST,
Html(
BadRequestTemplate {
error: &error.body_text(),
}
.render()
.expect("shouldn't fail to render BadRequestTemplate"),
),
)
.into_response(),
SameyError::BadRequest(error) => (
StatusCode::BAD_REQUEST,
Html(
BadRequestTemplate { error }
.render()
.expect("shouldn't fail to render BadRequestTemplate"),
),
)
.into_response(),
SameyError::NotFound => ( SameyError::NotFound => (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Html( Html(
@ -78,10 +121,24 @@ impl IntoResponse for SameyError {
), ),
) )
.into_response(), .into_response(),
SameyError::Authentication(_) => { SameyError::Authentication(_) => (
(StatusCode::UNAUTHORIZED, "Not authorized").into_response() StatusCode::UNAUTHORIZED,
} Html(
SameyError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden").into_response(), UnauthorizedTemplate {}
.render()
.expect("shouldn't fail to render UnauthorizedTemplate"),
),
)
.into_response(),
SameyError::Forbidden => (
StatusCode::FORBIDDEN,
Html(
ForbiddenTemplate {}
.render()
.expect("shouldn't fail to render ForbiddenTemplate"),
),
)
.into_response(),
} }
} }
} }

View file

@ -3,9 +3,9 @@ use std::collections::HashSet;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use samey_migration::{Expr, Query}; use samey_migration::{Expr, Query};
use sea_orm::{ use sea_orm::{
ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoSimpleExpr, ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoIdentity,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns, SelectModel, IntoSimpleExpr, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns,
Selector, SelectModel, Selector,
}; };
use crate::{ use crate::{
@ -276,3 +276,18 @@ pub(crate) fn filter_posts_by_user(
), ),
} }
} }
pub(crate) async fn clean_dangling_tags(db: &DatabaseConnection) -> Result<(), SameyError> {
let dangling_tags = SameyTag::find()
.select_column_as(samey_tag_post::Column::Id.count(), "count")
.left_join(SameyTagPost)
.group_by(samey_tag::Column::Id)
.having(Expr::column("count".into_identity()).eq(0))
.all(db)
.await?;
SameyTag::delete_many()
.filter(samey_tag::Column::Id.is_in(dangling_tags.into_iter().map(|tag| tag.id)))
.exec(db)
.await?;
Ok(())
}

View file

@ -40,8 +40,8 @@ use crate::{
}, },
error::SameyError, error::SameyError,
query::{ query::{
PoolPost, PostOverview, PostPoolData, filter_posts_by_user, get_pool_data_for_post, PoolPost, PostOverview, PostPoolData, clean_dangling_tags, filter_posts_by_user,
get_posts_in_pool, get_tags_for_post, search_posts, get_pool_data_for_post, get_posts_in_pool, get_tags_for_post, search_posts,
}, },
video::{generate_thumbnail, get_dimensions_for_video}, video::{generate_thumbnail, get_dimensions_for_video},
}; };
@ -183,24 +183,17 @@ pub(crate) async fn login(
mut auth_session: AuthSession, mut auth_session: AuthSession,
Form(credentials): Form<Credentials>, Form(credentials): Form<Credentials>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let user = match auth_session.authenticate(credentials).await { let user = match auth_session.authenticate(credentials).await? {
Ok(Some(user)) => user, Some(user) => user,
Ok(None) => return Err(SameyError::Authentication("Invalid credentials".into())), None => return Err(SameyError::Authentication("Invalid credentials".into())),
Err(_) => return Err(SameyError::Other("Auth session error".into())),
}; };
auth_session auth_session.login(&user).await?;
.login(&user)
.await
.map_err(|_| SameyError::Other("Login failed".into()))?;
Ok(Redirect::to("/")) Ok(Redirect::to("/"))
} }
pub(crate) async fn logout(mut auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> { pub(crate) async fn logout(mut auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> {
auth_session auth_session.logout().await?;
.logout()
.await
.map_err(|_| SameyError::Other("Logout error".into()))?;
Ok(Redirect::to("/")) Ok(Redirect::to("/"))
} }
@ -260,10 +253,9 @@ impl FromStr for Format {
"application/x-matroska" | "video/mastroska" => Ok(Self::Video(".mkv")), "application/x-matroska" | "video/mastroska" => Ok(Self::Video(".mkv")),
"video/quicktime" => Ok(Self::Video(".mov")), "video/quicktime" => Ok(Self::Video(".mov")),
_ => Ok(Self::Image( _ => Ok(Self::Image(
ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other(format!( ImageFormat::from_mime_type(content_type).ok_or(SameyError::BadRequest(
"Unknown content type: {}", format!("Unknown content type: {}", content_type),
content_type ))?,
)))?,
)), )),
} }
} }
@ -334,7 +326,7 @@ pub(crate) async fn upload(
"media-file" => { "media-file" => {
let content_type = field let content_type = field
.content_type() .content_type()
.ok_or(SameyError::Other("Missing content type".into()))?; .ok_or(SameyError::BadRequest("Missing content type".into()))?;
match Format::from_str(content_type)? { match Format::from_str(content_type)? {
format @ Format::Video(video_format) => { format @ Format::Video(video_format) => {
media_type = Some(format.media_type()); media_type = Some(format.media_type());
@ -502,7 +494,9 @@ pub(crate) async fn upload(
Ok(Redirect::to(&format!("/post/{}", uploaded_post))) Ok(Redirect::to(&format!("/post/{}", uploaded_post)))
} else { } else {
Err(SameyError::Other("Missing parameters for upload".into())) Err(SameyError::BadRequest(
"Missing parameters for upload".into(),
))
} }
} }
@ -1376,25 +1370,24 @@ pub(crate) async fn update_settings(
let mut configs = vec![]; let mut configs = vec![];
if !body.application_name.is_empty() { let application_name = body.application_name.trim();
if !application_name.is_empty() {
let _ = mem::replace( let _ = mem::replace(
&mut app_config.write().await.application_name, &mut app_config.write().await.application_name,
body.application_name.clone(), application_name.into(),
); );
configs.push(samey_config::ActiveModel { configs.push(samey_config::ActiveModel {
key: Set(APPLICATION_NAME_KEY.into()), key: Set(APPLICATION_NAME_KEY.into()),
data: Set(body.application_name.into()), data: Set(application_name.into()),
..Default::default() ..Default::default()
}); });
} }
let _ = mem::replace( let base_url = body.base_url.trim_end_matches('/');
&mut app_config.write().await.base_url, let _ = mem::replace(&mut app_config.write().await.base_url, base_url.into());
body.base_url.clone(),
);
configs.push(samey_config::ActiveModel { configs.push(samey_config::ActiveModel {
key: Set(BASE_URL_KEY.into()), key: Set(BASE_URL_KEY.into()),
data: Set(body.base_url.into()), data: Set(base_url.into()),
..Default::default() ..Default::default()
}); });
@ -1772,6 +1765,12 @@ pub(crate) async fn submit_post_details(
.all(&db) .all(&db)
.await?; .await?;
tokio::spawn(async move {
if let Err(err) = clean_dangling_tags(&db).await {
println!("Error when cleaning dangling tags - {}", err);
}
});
Ok(Html( Ok(Html(
SubmitPostDetailsTemplate { SubmitPostDetailsTemplate {
post, post,
@ -1887,6 +1886,9 @@ pub(crate) async fn delete_post(
tokio::spawn(async move { tokio::spawn(async move {
let _ = std::fs::remove_file(files_dir.join(post.media)); let _ = std::fs::remove_file(files_dir.join(post.media));
let _ = std::fs::remove_file(files_dir.join(post.thumbnail)); let _ = std::fs::remove_file(files_dir.join(post.thumbnail));
if let Err(err) = clean_dangling_tags(&db).await {
println!("Error when cleaning dangling tags - {}", err);
}
}); });
Ok(Redirect::to("/")) Ok(Redirect::to("/"))

View file

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

View file

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

View file

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

View file

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

View file

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