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.
- 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
- [ ] Text media
- [ ] Improve CSS
- [ ] Background tasks for garbage collection (dangling tags)
- [ ] User management
- [ ] Display thumbnails on post selection
- [ ] Testing
- [ ] Lossless compression
- [ ] Migrate to Cot...?
## Running

View file

@ -221,3 +221,9 @@ impl ExpiredDeletion for SessionStorage {
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},
};
#[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)]
#[template(path = "pages/not_found.html")]
struct NotFoundTemplate;
#[derive(askama::Template)]
#[template(path = "pages/internal_server_error.html")]
struct InternalServerErrorTemplate;
/// Errors from Samey.
#[derive(Debug, thiserror::Error)]
pub enum SameyError {
@ -54,7 +72,6 @@ pub enum SameyError {
impl IntoResponse for SameyError {
fn into_response(self) -> Response {
println!("Server error - {}", &self);
match &self {
SameyError::IntConversion(_)
| SameyError::IntParse(_)
@ -64,11 +81,37 @@ impl IntoResponse for SameyError {
| SameyError::Database(_)
| SameyError::Image(_)
| SameyError::Other(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong!").into_response()
}
SameyError::Multipart(_) | SameyError::BadRequest(_) => {
(StatusCode::BAD_REQUEST, "Invalid request").into_response()
println!("Internal server error - {:?}", &self);
(
StatusCode::INTERNAL_SERVER_ERROR,
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 => (
StatusCode::NOT_FOUND,
Html(
@ -78,10 +121,24 @@ impl IntoResponse for SameyError {
),
)
.into_response(),
SameyError::Authentication(_) => {
(StatusCode::UNAUTHORIZED, "Not authorized").into_response()
}
SameyError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden").into_response(),
SameyError::Authentication(_) => (
StatusCode::UNAUTHORIZED,
Html(
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 samey_migration::{Expr, Query};
use sea_orm::{
ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoSimpleExpr,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns, SelectModel,
Selector,
ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoIdentity,
IntoSimpleExpr, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns,
SelectModel, Selector,
};
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,
query::{
PoolPost, PostOverview, PostPoolData, filter_posts_by_user, get_pool_data_for_post,
get_posts_in_pool, get_tags_for_post, search_posts,
PoolPost, PostOverview, PostPoolData, clean_dangling_tags, 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},
};
@ -183,24 +183,17 @@ pub(crate) async fn login(
mut auth_session: AuthSession,
Form(credentials): Form<Credentials>,
) -> Result<impl IntoResponse, SameyError> {
let user = match auth_session.authenticate(credentials).await {
Ok(Some(user)) => user,
Ok(None) => return Err(SameyError::Authentication("Invalid credentials".into())),
Err(_) => return Err(SameyError::Other("Auth session error".into())),
let user = match auth_session.authenticate(credentials).await? {
Some(user) => user,
None => return Err(SameyError::Authentication("Invalid credentials".into())),
};
auth_session
.login(&user)
.await
.map_err(|_| SameyError::Other("Login failed".into()))?;
auth_session.login(&user).await?;
Ok(Redirect::to("/"))
}
pub(crate) async fn logout(mut auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> {
auth_session
.logout()
.await
.map_err(|_| SameyError::Other("Logout error".into()))?;
auth_session.logout().await?;
Ok(Redirect::to("/"))
}
@ -260,10 +253,9 @@ impl FromStr for Format {
"application/x-matroska" | "video/mastroska" => Ok(Self::Video(".mkv")),
"video/quicktime" => Ok(Self::Video(".mov")),
_ => Ok(Self::Image(
ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other(format!(
"Unknown content type: {}",
content_type
)))?,
ImageFormat::from_mime_type(content_type).ok_or(SameyError::BadRequest(
format!("Unknown content type: {}", content_type),
))?,
)),
}
}
@ -334,7 +326,7 @@ pub(crate) async fn upload(
"media-file" => {
let content_type = field
.content_type()
.ok_or(SameyError::Other("Missing content type".into()))?;
.ok_or(SameyError::BadRequest("Missing content type".into()))?;
match Format::from_str(content_type)? {
format @ Format::Video(video_format) => {
media_type = Some(format.media_type());
@ -502,7 +494,9 @@ pub(crate) async fn upload(
Ok(Redirect::to(&format!("/post/{}", uploaded_post)))
} 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![];
if !body.application_name.is_empty() {
let application_name = body.application_name.trim();
if !application_name.is_empty() {
let _ = mem::replace(
&mut app_config.write().await.application_name,
body.application_name.clone(),
application_name.into(),
);
configs.push(samey_config::ActiveModel {
key: Set(APPLICATION_NAME_KEY.into()),
data: Set(body.application_name.into()),
data: Set(application_name.into()),
..Default::default()
});
}
let _ = mem::replace(
&mut app_config.write().await.base_url,
body.base_url.clone(),
);
let base_url = body.base_url.trim_end_matches('/');
let _ = mem::replace(&mut app_config.write().await.base_url, base_url.into());
configs.push(samey_config::ActiveModel {
key: Set(BASE_URL_KEY.into()),
data: Set(body.base_url.into()),
data: Set(base_url.into()),
..Default::default()
});
@ -1772,6 +1765,12 @@ pub(crate) async fn submit_post_details(
.all(&db)
.await?;
tokio::spawn(async move {
if let Err(err) = clean_dangling_tags(&db).await {
println!("Error when cleaning dangling tags - {}", err);
}
});
Ok(Html(
SubmitPostDetailsTemplate {
post,
@ -1887,6 +1886,9 @@ pub(crate) async fn delete_post(
tokio::spawn(async move {
let _ = std::fs::remove_file(files_dir.join(post.media));
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("/"))

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