fix: harden html responses and cache chat sidebar data

Use strict template response handling and sanitized template user context, then add an in-process conversation archive cache with mutation-driven invalidation for chat sidebar renders.
This commit is contained in:
Per Stark
2026-02-14 17:47:14 +01:00
parent a3f207beb1
commit f93c06b347
12 changed files with 173 additions and 60 deletions
+20 -2
View File
@@ -17,10 +17,16 @@ use crate::html_state::HtmlState;
pub struct AccountPageData {
timezones: Vec<String>,
theme_options: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
selected_timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
selected_theme: Option<String>,
}
pub async fn show_account_page(
RequireUser(_user): RequireUser,
RequireUser(user): RequireUser,
State(_state): State<HtmlState>,
) -> Result<impl IntoResponse, HtmlError> {
let timezones = TZ_VARIANTS
@@ -40,6 +46,9 @@ pub async fn show_account_page(
AccountPageData {
timezones,
theme_options,
api_key: user.api_key,
selected_timezone: None,
selected_theme: None,
},
))
}
@@ -50,7 +59,7 @@ pub async fn set_api_key(
auth: AuthSessionType,
) -> Result<impl IntoResponse, HtmlError> {
// Generate and set the API key
User::set_api_key(&user.id, &state.db).await?;
let api_key = User::set_api_key(&user.id, &state.db).await?;
// Clear the cache so new requests have access to the user with api key
auth.cache_clear_user(user.id.to_string());
@@ -62,6 +71,9 @@ pub async fn set_api_key(
AccountPageData {
timezones: vec![],
theme_options: vec![],
api_key: Some(api_key),
selected_timezone: None,
selected_theme: None,
},
))
}
@@ -108,6 +120,9 @@ pub async fn update_timezone(
AccountPageData {
timezones,
theme_options: vec![],
api_key: None,
selected_timezone: Some(form.timezone),
selected_theme: None,
},
))
}
@@ -142,6 +157,9 @@ pub async fn update_theme(
AccountPageData {
timezones: vec![],
theme_options,
api_key: None,
selected_timezone: None,
selected_theme: Some(form.theme),
},
))
}
+2 -6
View File
@@ -1,8 +1,4 @@
use axum::{
extract::State,
response::{Html, IntoResponse},
Form,
};
use axum::{extract::State, response::IntoResponse, Form};
use axum_htmx::HxBoosted;
use serde::{Deserialize, Serialize};
@@ -46,7 +42,7 @@ pub async fn authenticate_user(
let user = match User::authenticate(&form.email, &form.password, &state.db).await {
Ok(user) => user,
Err(_) => {
return Ok(Html("<p>Incorrect email or password </p>").into_response());
return Ok(TemplateResponse::bad_request("Incorrect email or password").into_response());
}
};
+2 -6
View File
@@ -1,8 +1,4 @@
use axum::{
extract::State,
response::{Html, IntoResponse},
Form,
};
use axum::{extract::State, response::IntoResponse, Form};
use axum_htmx::HxBoosted;
use serde::{Deserialize, Serialize};
@@ -57,7 +53,7 @@ pub async fn process_signup_and_show_verification(
Ok(user) => user,
Err(e) => {
tracing::error!("{:?}", e);
return Ok(Html(format!("<p>{e}</p>")).into_response());
return Ok(TemplateResponse::bad_request(&e.to_string()).into_response());
}
};
+5 -1
View File
@@ -73,6 +73,7 @@ pub async fn show_initialized_chat(
state.db.store_item(conversation.clone()).await?;
state.db.store_item(ai_message.clone()).await?;
state.db.store_item(user_message.clone()).await?;
state.invalidate_conversation_archive_cache(&user.id).await;
let messages = vec![user_message, ai_message];
@@ -178,7 +179,7 @@ pub async fn new_chat_user_message(
None => return Ok(Redirect::to("/").into_response()),
};
let conversation = Conversation::new(user.id, "New chat".to_string());
let conversation = Conversation::new(user.id.clone(), "New chat".to_string());
let user_message = Message::new(
conversation.id.clone(),
MessageRole::User,
@@ -188,6 +189,7 @@ pub async fn new_chat_user_message(
state.db.store_item(conversation.clone()).await?;
state.db.store_item(user_message.clone()).await?;
state.invalidate_conversation_archive_cache(&user.id).await;
#[derive(Serialize)]
struct SSEResponseInitData {
@@ -252,6 +254,7 @@ pub async fn patch_conversation_title(
Form(form): Form<PatchConversationTitle>,
) -> Result<impl IntoResponse, HtmlError> {
Conversation::patch_title(&conversation_id, &user.id, &form.title, &state.db).await?;
state.invalidate_conversation_archive_cache(&user.id).await;
Ok(TemplateResponse::new_template(
"sidebar.html",
@@ -281,6 +284,7 @@ pub async fn delete_conversation(
.db
.delete_item::<Conversation>(&conversation_id)
.await?;
state.invalidate_conversation_archive_cache(&user.id).await;
Ok(TemplateResponse::new_template(
"sidebar.html",
+16 -14
View File
@@ -5,7 +5,7 @@ use axum::{
http::StatusCode,
response::{
sse::{Event, KeepAlive},
Html, IntoResponse, Response, Sse,
IntoResponse, Response, Sse,
},
};
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
@@ -56,12 +56,10 @@ pub async fn show_ingest_form(
pub async fn hide_ingest_form(
RequireUser(_user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
Ok(
Html(
"<a class='btn btn-primary' hx-get='/ingest-form' hx-swap='outerHTML'>Add Content</a>",
)
.into_response(),
)
Ok(TemplateResponse::new_template(
"ingestion/add_content_button.html",
(),
))
}
#[derive(Debug, TryFromMultipart)]
@@ -80,11 +78,10 @@ pub async fn process_ingest_form(
TypedMultipart(input): TypedMultipart<IngestionParams>,
) -> Result<Response, HtmlError> {
if input.content.as_ref().is_none_or(|c| c.len() < 2) && input.files.is_empty() {
return Ok((
StatusCode::BAD_REQUEST,
"You need to either add files or content",
)
.into_response());
return Ok(
TemplateResponse::bad_request("You need to either add files or content")
.into_response(),
);
}
let content_bytes = input.content.as_ref().map_or(0, |c| c.len());
@@ -102,10 +99,15 @@ pub async fn process_ingest_form(
) {
Ok(()) => {}
Err(IngestValidationError::PayloadTooLarge(message)) => {
return Ok((StatusCode::PAYLOAD_TOO_LARGE, message).into_response());
return Ok(TemplateResponse::error(
StatusCode::PAYLOAD_TOO_LARGE,
"Payload Too Large",
&message,
)
.into_response());
}
Err(IngestValidationError::BadRequest(message)) => {
return Ok((StatusCode::BAD_REQUEST, message).into_response());
return Ok(TemplateResponse::bad_request(&message).into_response());
}
}