mirror of
https://github.com/perstarkse/minne.git
synced 2026-04-20 16:01:22 +02:00
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:
@@ -1,9 +1,13 @@
|
||||
use common::storage::types::conversation::Conversation;
|
||||
use common::storage::{db::SurrealDbClient, store::StorageManager};
|
||||
use common::utils::embedding::EmbeddingProvider;
|
||||
use common::utils::template_engine::{ProvidesTemplateEngine, TemplateEngine};
|
||||
use common::{create_template_engine, storage::db::ProvidesDb, utils::config::AppConfig};
|
||||
use retrieval_pipeline::{reranking::RerankerPool, RetrievalStrategy};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{OpenAIClientType, SessionStoreType};
|
||||
@@ -18,8 +22,17 @@ pub struct HtmlState {
|
||||
pub storage: StorageManager,
|
||||
pub reranker_pool: Option<Arc<RerankerPool>>,
|
||||
pub embedding_provider: Arc<EmbeddingProvider>,
|
||||
conversation_archive_cache: Arc<RwLock<HashMap<String, ConversationArchiveCacheEntry>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ConversationArchiveCacheEntry {
|
||||
conversations: Vec<Conversation>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
const CONVERSATION_ARCHIVE_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
impl HtmlState {
|
||||
pub async fn new_with_resources(
|
||||
db: Arc<SurrealDbClient>,
|
||||
@@ -44,6 +57,7 @@ impl HtmlState {
|
||||
storage,
|
||||
reranker_pool,
|
||||
embedding_provider,
|
||||
conversation_archive_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,6 +68,38 @@ impl HtmlState {
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(RetrievalStrategy::Default)
|
||||
}
|
||||
|
||||
pub async fn get_cached_conversation_archive(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Option<Vec<Conversation>> {
|
||||
let cache = self.conversation_archive_cache.read().await;
|
||||
let entry = cache.get(user_id)?;
|
||||
if entry.expires_at <= Instant::now() {
|
||||
return None;
|
||||
}
|
||||
Some(entry.conversations.clone())
|
||||
}
|
||||
|
||||
pub async fn set_cached_conversation_archive(
|
||||
&self,
|
||||
user_id: &str,
|
||||
conversations: Vec<Conversation>,
|
||||
) {
|
||||
let mut cache = self.conversation_archive_cache.write().await;
|
||||
cache.insert(
|
||||
user_id.to_string(),
|
||||
ConversationArchiveCacheEntry {
|
||||
conversations,
|
||||
expires_at: Instant::now() + CONVERSATION_ARCHIVE_CACHE_TTL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn invalidate_conversation_archive_cache(&self, user_id: &str) {
|
||||
let mut cache = self.conversation_archive_cache.write().await;
|
||||
cache.remove(user_id);
|
||||
}
|
||||
}
|
||||
impl ProvidesDb for HtmlState {
|
||||
fn db(&self) -> &Arc<SurrealDbClient> {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub trait ProvidesHtmlState {
|
||||
fn html_state(&self) -> &HtmlState;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TemplateKind {
|
||||
Full(String),
|
||||
Partial(String, String),
|
||||
@@ -114,12 +114,33 @@ impl IntoResponse for TemplateResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TemplateUser {
|
||||
id: String,
|
||||
email: String,
|
||||
admin: bool,
|
||||
timezone: String,
|
||||
theme: String,
|
||||
}
|
||||
|
||||
impl From<&User> for TemplateUser {
|
||||
fn from(user: &User) -> Self {
|
||||
Self {
|
||||
id: user.id.clone(),
|
||||
email: user.email.clone(),
|
||||
admin: user.admin,
|
||||
timezone: user.timezone.clone(),
|
||||
theme: user.theme.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ContextWrapper<'a> {
|
||||
user_theme: &'a str,
|
||||
initial_theme: &'a str,
|
||||
is_authenticated: bool,
|
||||
user: Option<&'a User>,
|
||||
user: Option<&'a TemplateUser>,
|
||||
conversation_archive: Vec<Conversation>,
|
||||
#[serde(flatten)]
|
||||
context: HashMap<String, Value>,
|
||||
@@ -138,6 +159,7 @@ where
|
||||
let mut initial_theme = Theme::System.initial_theme();
|
||||
let mut is_authenticated = false;
|
||||
let mut current_user_id = None;
|
||||
let mut current_user = None;
|
||||
|
||||
{
|
||||
if let Some(auth) = req.extensions().get::<AuthSessionType>() {
|
||||
@@ -146,6 +168,7 @@ where
|
||||
current_user_id = Some(user.id.clone());
|
||||
user_theme = user.theme.as_str();
|
||||
initial_theme = user.theme.initial_theme();
|
||||
current_user = Some(TemplateUser::from(user));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,17 +181,48 @@ where
|
||||
if let Some(template_response) = response.extensions().get::<TemplateResponse>().cloned() {
|
||||
let template_engine = state.template_engine();
|
||||
|
||||
let mut current_user = None;
|
||||
let mut conversation_archive = Vec::new();
|
||||
|
||||
if let Some(user_id) = current_user_id {
|
||||
let html_state = state.html_state();
|
||||
if let Ok(Some(user)) = html_state.db.get_item::<User>(&user_id).await {
|
||||
// Fetch conversation archive globally for authenticated users
|
||||
if let Ok(archive) = User::get_user_conversations(&user.id, &html_state.db).await {
|
||||
let should_load_conversation_archive =
|
||||
matches!(&template_response.template_kind, TemplateKind::Full(_));
|
||||
|
||||
if should_load_conversation_archive {
|
||||
if let Some(user_id) = current_user_id {
|
||||
let html_state = state.html_state();
|
||||
if let Some(cached_archive) =
|
||||
html_state.get_cached_conversation_archive(&user_id).await
|
||||
{
|
||||
conversation_archive = cached_archive;
|
||||
} else if let Ok(archive) =
|
||||
User::get_user_conversations(&user_id, &html_state.db).await
|
||||
{
|
||||
html_state
|
||||
.set_cached_conversation_archive(&user_id, archive.clone())
|
||||
.await;
|
||||
conversation_archive = archive;
|
||||
}
|
||||
current_user = Some(user);
|
||||
}
|
||||
}
|
||||
|
||||
fn context_to_map(
|
||||
value: &Value,
|
||||
) -> Result<HashMap<String, Value>, minijinja::value::ValueKind> {
|
||||
match value.kind() {
|
||||
minijinja::value::ValueKind::Map => {
|
||||
let mut map = HashMap::new();
|
||||
if let Ok(keys) = value.try_iter() {
|
||||
for key in keys {
|
||||
if let Ok(val) = value.get_item(&key) {
|
||||
map.insert(key.to_string(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
minijinja::value::ValueKind::None | minijinja::value::ValueKind::Undefined => {
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
other => Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,19 +237,15 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// Convert minijinja::Value to HashMap if it's a map, otherwise use empty HashMap
|
||||
let context_map = if template_response.context.kind() == minijinja::value::ValueKind::Map {
|
||||
let mut map = HashMap::new();
|
||||
if let Ok(keys) = template_response.context.try_iter() {
|
||||
for key in keys {
|
||||
if let Ok(val) = template_response.context.get_item(&key) {
|
||||
map.insert(key.to_string(), val);
|
||||
}
|
||||
}
|
||||
let context_map = match context_to_map(&template_response.context) {
|
||||
Ok(map) => map,
|
||||
Err(kind) => {
|
||||
error!(
|
||||
"Template context must be a map or unit, got kind={:?} for template_kind={:?}",
|
||||
kind, template_response.template_kind
|
||||
);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Html(fallback_error())).into_response();
|
||||
}
|
||||
map
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
let context = ContextWrapper {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">API Key</div>
|
||||
{% block api_key_section %}
|
||||
{% if user.api_key %}
|
||||
{% if api_key %}
|
||||
<div class="relative">
|
||||
<input id="api_key_input" type="text" name="api_key" value="{{ user.api_key }}"
|
||||
<input id="api_key_input" type="text" name="api_key" value="{{ api_key }}"
|
||||
class="nb-input w-full pr-14" disabled />
|
||||
<button type="button" id="copy_api_key_btn" onclick="copy_api_key()"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-2 nb-btn btn-sm" aria-label="Copy API key"
|
||||
@@ -48,9 +48,10 @@
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Timezone</div>
|
||||
{% block timezone_section %}
|
||||
{% set active_timezone = selected_timezone|default(user.timezone) %}
|
||||
<select name="timezone" class="nb-select w-full" hx-patch="/update-timezone" hx-swap="outerHTML">
|
||||
{% for tz in timezones %}
|
||||
<option value="{{ tz }}" {% if tz==user.timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
<option value="{{ tz }}" {% if tz==active_timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endblock %}
|
||||
@@ -59,13 +60,14 @@
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Theme</div>
|
||||
{% block theme_section %}
|
||||
{% set active_theme = selected_theme|default(user.theme) %}
|
||||
<select name="theme" class="nb-select w-full" hx-patch="/update-theme" hx-swap="outerHTML">
|
||||
{% for option in theme_options %}
|
||||
<option value="{{ option }}" {% if option==user.theme %}selected{% endif %}>{{ option }}</option>
|
||||
<option value="{{ option }}" {% if option==active_theme %}selected{% endif %}>{{ option }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<script>
|
||||
document.documentElement.setAttribute('data-theme-preference', '{{ user.theme }}');
|
||||
document.documentElement.setAttribute('data-theme-preference', '{{ active_theme }}');
|
||||
</script>
|
||||
{% endblock %}
|
||||
</label>
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
{% block auth_content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<div class="u-hairline mb-3"></div>
|
||||
|
||||
<form hx-post="/signin" hx-target="#login-result" class="flex flex-col gap-2">
|
||||
<form hx-post="/signin" hx-swap="none" class="flex flex-col gap-2">
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
|
||||
<input name="email" type="email" placeholder="Email" class="nb-input w-full validator" required />
|
||||
@@ -19,8 +19,6 @@
|
||||
minlength="8" />
|
||||
</label>
|
||||
|
||||
<div class="mt-1 text-error" id="login-result"></div>
|
||||
|
||||
<div class="form-control mt-1">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" name="remember_me" class="nb-checkbox" />
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
<div class="u-hairline mb-3"></div>
|
||||
|
||||
<form hx-post="/signup" hx-target="#signup-result" class="flex flex-col gap-4">
|
||||
<form hx-post="/signup" hx-swap="none" class="flex flex-col gap-4">
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
|
||||
<input type="email" placeholder="Email" name="email" required class="nb-input w-full validator" />
|
||||
@@ -31,7 +31,6 @@
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div class="mt-2 text-error" id="signup-result"></div>
|
||||
<div class="form-control mt-1">
|
||||
<button id="submit-btn" class="nb-btn nb-cta w-full">Create Account</button>
|
||||
</div>
|
||||
|
||||
1
html-router/templates/ingestion/add_content_button.html
Normal file
1
html-router/templates/ingestion/add_content_button.html
Normal file
@@ -0,0 +1 @@
|
||||
<a class="btn btn-primary" hx-get="/ingest-form" hx-target="#modal" hx-swap="innerHTML">Add Content</a>
|
||||
Reference in New Issue
Block a user