From 920931ba1787c609bdc476e3ebca0f6ff0298bca Mon Sep 17 00:00:00 2001
From: Jon Seager
Date: Fri, 13 Feb 2026 16:17:56 +0000
Subject: [PATCH] refactor: fix template review findings (security, macros,
tokens)
Address findings from the templates code review:
- Fix XSS in admin.html onclick handlers via data attributes
- Fix XSS in 5 edit page signal initializations via JSON serialization
- Fix register.html token exposure by moving to data attribute
- Add entity_icon, quick_notes_toggles, add_form_submit macros
- Replace hardcoded colors with design tokens (warning, error, success)
- Add warning design tokens to CSS theme
- Scope MutationObserver to main element
- Add defer to webauthn.js script tags
- Refactor login/register JS to arrow functions
- Guard lightbox script behind image_url check
- Fix else-if to elif in 5 templates
---
src/application/routes/app/bags.rs | 23 ++-
src/application/routes/app/cafes.rs | 27 ++-
src/application/routes/app/gear.rs | 17 +-
src/application/routes/app/roasters.rs | 25 ++-
src/application/routes/app/roasts.rs | 33 +++-
src/presentation/web/templates.rs | 5 +
src/presentation/web/views/mod.rs | 30 ++++
static/css/input.css | 12 ++
templates/base.html | 11 +-
templates/pages/add.html | 169 ++----------------
templates/pages/admin.html | 10 +-
templates/pages/cafe.html | 4 +-
templates/pages/cli_callback.html | 4 +-
templates/pages/edit_bag.html | 6 +-
templates/pages/edit_brew.html | 54 +-----
templates/pages/edit_cafe.html | 9 +-
templates/pages/edit_gear.html | 5 +-
templates/pages/edit_roast.html | 9 +-
templates/pages/edit_roaster.html | 7 +-
templates/pages/gear.html | 4 +-
templates/pages/home.html | 5 +-
templates/pages/login.html | 54 +++---
templates/pages/register.html | 78 ++++----
templates/pages/roast.html | 4 +-
templates/pages/roaster.html | 4 +-
templates/partials/detail_cards.html | 20 +++
templates/partials/entity_icon.html | 4 +
templates/partials/forms/quick_notes.html | 57 ++++++
.../partials/forms/scan_result_form.html | 2 +-
templates/partials/image_section.html | 4 +-
templates/partials/image_upload.html | 4 +-
templates/partials/tab_bar.html | 7 +-
templates/partials/timeline_month.html | 5 +-
33 files changed, 354 insertions(+), 358 deletions(-)
create mode 100644 templates/partials/entity_icon.html
create mode 100644 templates/partials/forms/quick_notes.html
diff --git a/src/application/routes/app/bags.rs b/src/application/routes/app/bags.rs
index 816fc99..26927b5 100644
--- a/src/application/routes/app/bags.rs
+++ b/src/application/routes/app/bags.rs
@@ -80,6 +80,22 @@ pub(crate) async fn bag_edit_page(
let roast_options = load_roast_options(&state).await.map_err(map_app_error)?;
+ let roast_date = bag
+ .bag
+ .roast_date
+ .map(|d| d.to_string())
+ .unwrap_or_default();
+
+ use crate::presentation::web::views::build_signals_json;
+ use serde_json::Value;
+ let signals_json = build_signals_json(&[
+ ("_submitting", Value::Bool(false)),
+ ("_submit-error", Value::String(String::new())),
+ ("_roast-date", Value::String(roast_date.clone())),
+ ("_amount", serde_json::json!(bag.bag.amount)),
+ ("_remaining", serde_json::json!(bag.bag.remaining)),
+ ]);
+
let template = BagEditTemplate {
nav_active: "",
is_authenticated: true,
@@ -87,14 +103,11 @@ pub(crate) async fn bag_edit_page(
id: bag.bag.id.to_string(),
roast_id: bag.bag.roast_id.to_string(),
roast_label: format!("{} ({})", bag.roast_name, bag.roaster_name),
- roast_date: bag
- .bag
- .roast_date
- .map(|d| d.to_string())
- .unwrap_or_default(),
+ roast_date,
amount: bag.bag.amount,
remaining: bag.bag.remaining,
roast_options,
+ signals_json,
};
render_html(template).map(IntoResponse::into_response)
diff --git a/src/application/routes/app/cafes.rs b/src/application/routes/app/cafes.rs
index e20c3e9..15e6586 100644
--- a/src/application/routes/app/cafes.rs
+++ b/src/application/routes/app/cafes.rs
@@ -59,18 +59,37 @@ pub(crate) async fn cafe_edit_page(
let image_url = resolve_image_url(&state, EntityType::Cafe, i64::from(id)).await;
+ let name = cafe.name;
+ let city = cafe.city;
+ let country = cafe.country;
+ let website = cafe.website.unwrap_or_default();
+
+ use crate::presentation::web::views::build_signals_json;
+ use serde_json::Value;
+ let signals_json = build_signals_json(&[
+ ("_submitting", Value::Bool(false)),
+ ("_submit-error", Value::String(String::new())),
+ ("_name", Value::String(name.clone())),
+ ("_city", Value::String(city.clone())),
+ ("_country", Value::String(country.clone())),
+ ("_latitude", serde_json::json!(cafe.latitude)),
+ ("_longitude", serde_json::json!(cafe.longitude)),
+ ("_website", Value::String(website.clone())),
+ ]);
+
let template = CafeEditTemplate {
nav_active: "",
is_authenticated: true,
version_info: &crate::VERSION_INFO,
id: cafe.id.to_string(),
- name: cafe.name,
- city: cafe.city,
- country: cafe.country,
+ name,
+ city,
+ country,
latitude: cafe.latitude,
longitude: cafe.longitude,
- website: cafe.website.unwrap_or_default(),
+ website,
image_url,
+ signals_json,
};
render_html(template).map(IntoResponse::into_response)
diff --git a/src/application/routes/app/gear.rs b/src/application/routes/app/gear.rs
index 9953d41..b7f88a4 100644
--- a/src/application/routes/app/gear.rs
+++ b/src/application/routes/app/gear.rs
@@ -58,15 +58,28 @@ pub(crate) async fn gear_edit_page(
let image_url = resolve_image_url(&state, EntityType::Gear, i64::from(id)).await;
+ let make = gear.make;
+ let model = gear.model;
+
+ use crate::presentation::web::views::build_signals_json;
+ use serde_json::Value;
+ let signals_json = build_signals_json(&[
+ ("_submitting", Value::Bool(false)),
+ ("_submit-error", Value::String(String::new())),
+ ("_make", Value::String(make.clone())),
+ ("_model", Value::String(model.clone())),
+ ]);
+
let template = GearEditTemplate {
nav_active: "",
is_authenticated: true,
version_info: &crate::VERSION_INFO,
id: gear.id.to_string(),
category: gear.category.display_label().to_string(),
- make: gear.make,
- model: gear.model,
+ make,
+ model,
image_url,
+ signals_json,
};
render_html(template).map(IntoResponse::into_response)
diff --git a/src/application/routes/app/roasters.rs b/src/application/routes/app/roasters.rs
index 6ee573f..ff6315c 100644
--- a/src/application/routes/app/roasters.rs
+++ b/src/application/routes/app/roasters.rs
@@ -59,16 +59,33 @@ pub(crate) async fn roaster_edit_page(
let image_url = resolve_image_url(&state, EntityType::Roaster, i64::from(id)).await;
+ let name = roaster.name;
+ let country = roaster.country;
+ let city = roaster.city.unwrap_or_default();
+ let homepage = roaster.homepage.unwrap_or_default();
+
+ use crate::presentation::web::views::build_signals_json;
+ use serde_json::Value;
+ let signals_json = build_signals_json(&[
+ ("_submitting", Value::Bool(false)),
+ ("_submit-error", Value::String(String::new())),
+ ("_name", Value::String(name.clone())),
+ ("_country", Value::String(country.clone())),
+ ("_city", Value::String(city.clone())),
+ ("_homepage", Value::String(homepage.clone())),
+ ]);
+
let template = RoasterEditTemplate {
nav_active: "",
is_authenticated: true,
version_info: &crate::VERSION_INFO,
id: roaster.id.to_string(),
- name: roaster.name,
- country: roaster.country,
- city: roaster.city.unwrap_or_default(),
- homepage: roaster.homepage.unwrap_or_default(),
+ name,
+ country,
+ city,
+ homepage,
image_url,
+ signals_json,
};
render_html(template).map(IntoResponse::into_response)
diff --git a/src/application/routes/app/roasts.rs b/src/application/routes/app/roasts.rs
index c0438a5..983a32e 100644
--- a/src/application/routes/app/roasts.rs
+++ b/src/application/routes/app/roasts.rs
@@ -75,6 +75,26 @@ pub(crate) async fn roast_edit_page(
let image_url = resolve_image_url(&state, EntityType::Roast, i64::from(id)).await;
+ let name = roast.name;
+ let origin = roast.origin.unwrap_or_default();
+ let region = roast.region.unwrap_or_default();
+ let producer = roast.producer.unwrap_or_default();
+ let process = roast.process.unwrap_or_default();
+ let tasting_notes = roast.tasting_notes.join(", ");
+
+ use crate::presentation::web::views::build_signals_json;
+ use serde_json::Value;
+ let signals_json = build_signals_json(&[
+ ("_submitting", Value::Bool(false)),
+ ("_submit-error", Value::String(String::new())),
+ ("_name", Value::String(name.clone())),
+ ("_origin", Value::String(origin.clone())),
+ ("_region", Value::String(region.clone())),
+ ("_producer", Value::String(producer.clone())),
+ ("_process", Value::String(process.clone())),
+ ("_tasting-notes", Value::String(tasting_notes.clone())),
+ ]);
+
let template = RoastEditTemplate {
nav_active: "",
is_authenticated: true,
@@ -82,14 +102,15 @@ pub(crate) async fn roast_edit_page(
id: roast.id.to_string(),
roaster_id: roast.roaster_id.to_string(),
roaster_name: roaster.name,
- name: roast.name,
- origin: roast.origin.unwrap_or_default(),
- region: roast.region.unwrap_or_default(),
- producer: roast.producer.unwrap_or_default(),
- process: roast.process.unwrap_or_default(),
- tasting_notes: roast.tasting_notes.join(", "),
+ name,
+ origin,
+ region,
+ producer,
+ process,
+ tasting_notes,
roaster_options,
image_url,
+ signals_json,
};
render_html(template).map(IntoResponse::into_response)
diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs
index 406199a..448b64a 100644
--- a/src/presentation/web/templates.rs
+++ b/src/presentation/web/templates.rs
@@ -317,6 +317,7 @@ pub struct RoasterEditTemplate {
pub city: String,
pub homepage: String,
pub image_url: Option,
+ pub signals_json: String,
}
#[derive(Template)]
@@ -336,6 +337,7 @@ pub struct RoastEditTemplate {
pub tasting_notes: String,
pub roaster_options: Vec,
pub image_url: Option,
+ pub signals_json: String,
}
#[derive(Template)]
@@ -351,6 +353,7 @@ pub struct BagEditTemplate {
pub amount: f64,
pub remaining: f64,
pub roast_options: Vec,
+ pub signals_json: String,
}
#[derive(Template)]
@@ -393,6 +396,7 @@ pub struct CafeEditTemplate {
pub longitude: f64,
pub website: String,
pub image_url: Option,
+ pub signals_json: String,
}
#[derive(Template)]
@@ -422,6 +426,7 @@ pub struct GearEditTemplate {
pub make: String,
pub model: String,
pub image_url: Option,
+ pub signals_json: String,
}
#[derive(Template)]
diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs
index 0945f8c..f2eeb21 100644
--- a/src/presentation/web/views/mod.rs
+++ b/src/presentation/web/views/mod.rs
@@ -511,6 +511,36 @@ pub(crate) fn build_map_data(entries: &[(&str, u32)]) -> (String, u32) {
(parts.join(","), max)
}
+/// Build a JSON string for Datastar `data-signals` attribute initialization.
+///
+/// Signal names may use kebab-case (`_roaster-name`); they are automatically
+/// converted to camelCase (`_roasterName`) to match Datastar's internal store.
+/// The returned string is a JSON object suitable for use in `data-signals="{{ signals_json }}"`.
+/// Askama HTML-escapes `"` to `"`, which the browser decodes before Datastar parses it.
+pub fn build_signals_json(signals: &[(&str, serde_json::Value)]) -> String {
+ let mut map = serde_json::Map::new();
+ for (name, value) in signals {
+ map.insert(signals_kebab_to_camel(name), value.clone());
+ }
+ serde_json::Value::Object(map).to_string()
+}
+
+fn signals_kebab_to_camel(s: &str) -> String {
+ let mut result = String::with_capacity(s.len());
+ let mut cap_next = false;
+ for c in s.chars() {
+ if c == '-' {
+ cap_next = true;
+ } else if cap_next {
+ result.push(c.to_ascii_uppercase());
+ cap_next = false;
+ } else {
+ result.push(c);
+ }
+ }
+ result
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/static/css/input.css b/static/css/input.css
index be0f711..816c05c 100644
--- a/static/css/input.css
+++ b/static/css/input.css
@@ -43,6 +43,10 @@
--success-bg: #ecfdf5; /* green-50 */
--success-border: rgba(5, 150, 105, 0.3);
--success-text: #065f46; /* green-800 */
+ --warning: #d97706; /* amber-600 */
+ --warning-bg: #fffbeb; /* amber-50 */
+ --warning-border: rgba(217, 119, 6, 0.3);
+ --warning-text: #92400e; /* amber-800 */
}
[data-theme="dark"] {
@@ -74,6 +78,10 @@
--success-bg: rgba(5, 150, 105, 0.1);
--success-border: rgba(5, 150, 105, 0.4);
--success-text: #6ee7b7;
+ --warning: #fbbf24; /* amber-400 */
+ --warning-bg: rgba(217, 119, 6, 0.1);
+ --warning-border: rgba(217, 119, 6, 0.4);
+ --warning-text: #fcd34d; /* amber-300 */
}
/* ── Theme: map raw vars to Tailwind utilities ─────────────────── */
@@ -99,6 +107,10 @@
--color-success-bg: var(--success-bg);
--color-success-border: var(--success-border);
--color-success-text: var(--success-text);
+ --color-warning: var(--warning);
+ --color-warning-bg: var(--warning-bg);
+ --color-warning-border: var(--warning-border);
+ --color-warning-text: var(--warning-text);
}
/* ── Base: default border color ────────────────────────────────── */
diff --git a/templates/base.html b/templates/base.html
index 80de718..f5affc5 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -179,10 +179,13 @@
setupInfiniteScroll();
- const bodyObserver = new MutationObserver(() => {
- setupInfiniteScroll();
- });
- bodyObserver.observe(document.body, { childList: true, subtree: true });
+ const mainEl = document.querySelector("main");
+ if (mainEl) {
+ const mainObserver = new MutationObserver(() => {
+ setupInfiniteScroll();
+ });
+ mainObserver.observe(mainEl, { childList: true, subtree: true });
+ }
});
diff --git a/templates/pages/add.html b/templates/pages/add.html
index 656a24e..c0e1901 100644
--- a/templates/pages/add.html
+++ b/templates/pages/add.html
@@ -1,6 +1,8 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% import "partials/location_search.html" as location %}
+{% import "partials/detail_cards.html" as detail_cards %}
+{% import "partials/forms/quick_notes.html" as quick_notes %}
{% block title %}Brewlog · Add{% endblock %}
{% block content %}
@@ -190,21 +192,7 @@
{{ img::deferred_upload("roaster-image", "Add image (optional)") }}
-
-
-
-
+ {{ detail_cards::add_form_submit("plus", "Save Roaster") }}
@@ -410,21 +398,7 @@
{{ img::deferred_upload("roast-image", "Add image (optional)") }}
-
-
-
-
+ {{ detail_cards::add_form_submit("plus", "Save Roast") }}
{% endif %}
@@ -512,21 +486,7 @@
/>
-
-
-
-
+ {{ detail_cards::add_form_submit("plus", "Save Bag") }}
{% endif %}
@@ -552,7 +512,7 @@
to enable this form.
- {% else if grinder_options.is_empty() || brewer_options.is_empty() %}
+ {% elif grinder_options.is_empty() || brewer_options.is_empty() %}
Add gear first
@@ -915,74 +875,9 @@
-
-
Quick Notes
-
-
-
-
-
-
-
-
-
-
+ {{ quick_notes::quick_notes_toggles() }}
{{ img::deferred_upload("brew-image", "Add image (optional)") }}
-
-
-
-
+ {{ detail_cards::add_form_submit("beaker", "Save Brew") }}
{% endif %}
@@ -1051,21 +946,7 @@
{{ img::deferred_upload("gear-image", "Add image (optional)") }}
-
-
-
-
+ {{ detail_cards::add_form_submit("plus", "Save Gear") }}
@@ -1223,21 +1104,7 @@
{{ img::deferred_upload("cafe-image", "Add image (optional)") }}
-
-
-
-
+ {{ detail_cards::add_form_submit("plus", "Save Cafe") }}
@@ -1339,21 +1206,7 @@
-
-
-
-
+ {{ detail_cards::add_form_submit("plus", "Save Cup") }}
{% endif %}
diff --git a/templates/pages/admin.html b/templates/pages/admin.html
index 69f1785..e6a77f1 100644
--- a/templates/pages/admin.html
+++ b/templates/pages/admin.html
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Admin{% endblock %}
{% block head %}
-
+
{% endblock %}
{% block content %}
@@ -49,7 +49,9 @@
- {{ img::lightbox_script() }}
+ {% if image_url.is_some() %}
+ {{ img::lightbox_script() }}
+ {% endif %}
diff --git a/templates/pages/cli_callback.html b/templates/pages/cli_callback.html
index ce30a04..6721eec 100644
--- a/templates/pages/cli_callback.html
+++ b/templates/pages/cli_callback.html
@@ -5,14 +5,14 @@
{% if token.is_some() %}
- {{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }}
+ {{ icons::check_circle("h-10 w-10 text-success") }}
CLI Authenticated
Your CLI has been authenticated successfully. You can close this
window and return to the terminal.
- {% else if error.is_some() %}
+ {% elif error.is_some() %}
{{ icons::x_circle("h-10 w-10 text-error") }}
diff --git a/templates/pages/edit_bag.html b/templates/pages/edit_bag.html
index c2c1fe0..4eb1849 100644
--- a/templates/pages/edit_bag.html
+++ b/templates/pages/edit_bag.html
@@ -12,11 +12,7 @@
- {% else if is_authenticated %}
+ {% elif is_authenticated %}
Remove
diff --git a/templates/partials/image_upload.html b/templates/partials/image_upload.html
index 8ebe56a..fac1600 100644
--- a/templates/partials/image_upload.html
+++ b/templates/partials/image_upload.html
@@ -19,14 +19,14 @@
Remove
{% endif %}
- {% else if is_authenticated %}
+ {% elif is_authenticated %}
- {% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
+ {{ ei::entity_icon(tab.key, "h-4 w-4 shrink-0") }}
{{ tab.label }}
{% endfor %}
@@ -35,7 +36,7 @@
{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
+ >{{ ei::entity_icon(tab.key, "h-4 w-4 shrink-0") }}
{{ tab.label }}
{% endfor %}
@@ -61,7 +62,7 @@
{selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
{% endif %}"
>
- {% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
+ {{ ei::entity_icon(tab.key, "h-4 w-4 shrink-0") }}
{{ tab.label }}
{% endfor %}
diff --git a/templates/partials/timeline_month.html b/templates/partials/timeline_month.html
index c3f8301..cf4f4db 100644
--- a/templates/partials/timeline_month.html
+++ b/templates/partials/timeline_month.html
@@ -1,4 +1,5 @@
{% import "partials/icons.html" as icons %}
+{% import "partials/entity_icon.html" as ei %}
{# Category — always visible #}
- {% if event.entity_type == "brew" %}{{ icons::beaker("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-3 w-3 shrink-0") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-3 w-3 shrink-0") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-3 w-3 shrink-0") }}{% endif %}
+ {{ ei::entity_icon(event.entity_type, "h-3 w-3 shrink-0") }}
{{ event.kind_label }}
{# Relative date — shown when collapsed #}
- {% else if let Some(url) = detail.link %}
+ {% elif let Some(url) = detail.link %}