mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-02 04:53:11 +00:00
Fix XSS risks and add closed-form UX to order page
Add escapeHtml() for all scraped content in innerHTML, fix script injection via </script> in JSON, fix JWT base64url decoding, match backend two-step rounding in JS employeePrice(), disable qty buttons and submit when form is closed, add server-driven countdown from reopen_at, add duplicate order warning via localStorage, add back button after submission, embed favicon, use :g format for fractional discounts, and exclude dead loadRoster code when Google auth enabled.
This commit is contained in:
parent
bcce15b9e6
commit
763da24134
1 changed files with 79 additions and 33 deletions
|
|
@ -15,8 +15,6 @@ import sys
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import boto3
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT_DIR = PROJECT_ROOT / "output"
|
||||
CONFIG_PATH = PROJECT_ROOT / "config.json"
|
||||
|
|
@ -45,15 +43,15 @@ def generate_form(
|
|||
company_subsidy: float = 0,
|
||||
google_client_id: str = "",
|
||||
) -> str:
|
||||
meals_json = json.dumps(menu["meals"])
|
||||
roster_json = json.dumps(config.get("roster", []))
|
||||
meals_json = json.dumps(menu["meals"]).replace("</", "<\\/")
|
||||
roster_json = json.dumps(config.get("roster", [])).replace("</", "<\\/")
|
||||
deadline = config.get("order_deadline", "Thursday 11:59 PM")
|
||||
week = datetime.now().strftime("%Y-W%U")
|
||||
scraped_at = menu.get("scraped_at", "unknown")
|
||||
submit_url = f"{api_url}/api/submit-order" if api_url else "/api/submit-order"
|
||||
status_url = f"{api_url}/api/form-status/{week}" if api_url else f"/api/form-status/{week}"
|
||||
roster_url = f"{api_url}/api/roster" if api_url else "/api/roster"
|
||||
api_key_json = json.dumps(api_key)
|
||||
api_key_json = json.dumps(api_key).replace("</", "<\\/")
|
||||
has_discount = bulk_discount > 0 or company_subsidy > 0
|
||||
use_google_auth = bool(google_client_id)
|
||||
google_client_id_json = json.dumps(google_client_id)
|
||||
|
|
@ -103,7 +101,8 @@ function initGoogleAuth() {
|
|||
|
||||
function handleCredentialResponse(response) {
|
||||
googleCredential = response.credential;
|
||||
const payload = JSON.parse(atob(response.credential.split('.')[1]));
|
||||
const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(b64));
|
||||
googleUser = { name: payload.name, email: payload.email };
|
||||
|
||||
document.getElementById('auth-overlay').style.display = 'none';
|
||||
|
|
@ -119,6 +118,7 @@ function handleCredentialResponse(response) {
|
|||
}
|
||||
|
||||
updateTotal();
|
||||
checkDuplicateOrder();
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
|
|
@ -137,6 +137,7 @@ function signOut() {
|
|||
if use_google_auth:
|
||||
submit_order_js = """
|
||||
async function submitOrder() {
|
||||
if (formClosed) { alert('Orders are closed.'); return; }
|
||||
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; }
|
||||
|
||||
const items = Object.entries(quantities).map(([i, qty]) => ({
|
||||
|
|
@ -161,8 +162,9 @@ async function submitOrder() {
|
|||
if (res.ok) {
|
||||
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
|
||||
document.getElementById('success').style.display = 'block';
|
||||
document.getElementById('success-detail').textContent = `${googleUser.name} — $${data.total.toFixed(2)} total. You're all set!`;
|
||||
document.getElementById('success-detail').textContent = `${googleUser.name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
|
||||
document.querySelector('.sticky-footer').style.display = 'none';
|
||||
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
|
||||
} else {
|
||||
alert(data.error || 'Something went wrong.');
|
||||
btn.disabled = false;
|
||||
|
|
@ -178,6 +180,7 @@ async function submitOrder() {
|
|||
else:
|
||||
submit_order_js = """
|
||||
async function submitOrder() {
|
||||
if (formClosed) { alert('Orders are closed.'); return; }
|
||||
const name = document.getElementById('emp-name').value.trim();
|
||||
const email = document.getElementById('emp-email').value.trim();
|
||||
if (!name) { alert('Please enter your name.'); return; }
|
||||
|
|
@ -205,8 +208,9 @@ async function submitOrder() {
|
|||
if (res.ok) {
|
||||
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
|
||||
document.getElementById('success').style.display = 'block';
|
||||
document.getElementById('success-detail').textContent = `${name} — $${data.total.toFixed(2)} total. You're all set!`;
|
||||
document.getElementById('success-detail').textContent = `${name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
|
||||
document.querySelector('.sticky-footer').style.display = 'none';
|
||||
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
|
||||
} else {
|
||||
alert(data.error || 'Something went wrong.');
|
||||
btn.disabled = false;
|
||||
|
|
@ -226,6 +230,7 @@ async function submitOrder() {
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sea Haven — Meal Order ({week})</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABX0lEQVRYhe3TPWsUURjF8d+dnSxhoxA1xEJnQQsJGbBTrIIIvoEDNqkNiJ9BrPwI4jeY2kmVoKWkSVALK6MgwWLsJIWIBBFZLGaLTTLrJrX3X96X5znnPPcSiUQikf+dcHAhr+qLuIrT2MV77Owv9ycWy6u6i3tnOu5maTg/FUzjOz5gHe/KImsXkFf1ZTzH9ZbaNdbwElvby/0fwzsJFnANN3uJO/00zJ5MxmrcwqOyyD7uE5BX9Q08xh5O4BL+ZXkXvzQp9RKcS4OzaUukh9nDg7LIVkcFGI04r2pDZw+xgrlx1U516KdB93DnGhvYxBd8w08M0MXnssgmCx7O9RZuYzEw30sszATpXCeY2R/3AC/wTMu420gnHbgyHeY1sb3FH9wfc28DT8oiezOx63EE4DeeYqllb4BXGsevj+L4IEd4M6ysf6X5HUu4oEniE9bKIts5dtdIJBKJREb4C8ngTD+C9DtvAAAAAElFTkSuQmCC">
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; }}
|
||||
|
|
@ -262,6 +267,9 @@ header p {{ font-size: 0.85rem; opacity: 0.8; }}
|
|||
.submit-btn:hover:not(:disabled) {{ opacity: 0.85; }}
|
||||
.success-msg {{ text-align: center; padding: 60px 20px; }}
|
||||
.success-msg h2 {{ color: #15803d; margin-bottom: 8px; }}
|
||||
.back-btn {{ background: transparent; color: #1a1a2e; border: 2px solid #1a1a2e; padding: 12px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 20px; }}
|
||||
.back-btn:hover {{ background: #1a1a2e; color: #fff; }}
|
||||
.duplicate-warning {{ background: #fef3c7; color: #92400e; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; text-align: center; font-weight: 500; border: 1px solid #fcd34d; }}
|
||||
.meal-desc {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; }}
|
||||
.search-bar {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 16px; }}
|
||||
body {{ padding-bottom: 80px; }}
|
||||
|
|
@ -311,7 +319,8 @@ body {{ padding-bottom: 80px; }}
|
|||
</header>
|
||||
|
||||
<div class="deadline">Order deadline: {deadline}</div>
|
||||
{'<div class="discount-banner">Prices reflect employee cost after ' + (f"{int(bulk_discount)}% bulk discount" if bulk_discount > 0 else "") + (" + " if bulk_discount > 0 and company_subsidy > 0 else "") + (f"{int(company_subsidy)}% company subsidy" if company_subsidy > 0 else "") + "</div>" if has_discount else ""}
|
||||
<div class="duplicate-warning" id="duplicate-warning" style="display:none;">You've already submitted an order this week. Submitting again will replace your previous order.</div>
|
||||
{'<div class="discount-banner">Prices reflect employee cost after ' + (f"{bulk_discount:g}% bulk discount" if bulk_discount > 0 else "") + (" + " if bulk_discount > 0 and company_subsidy > 0 else "") + (f"{company_subsidy:g}% company subsidy" if company_subsidy > 0 else "") + "</div>" if has_discount else ""}
|
||||
|
||||
{auth_section_html}
|
||||
|
||||
|
|
@ -334,6 +343,7 @@ body {{ padding-bottom: 80px; }}
|
|||
<div id="success" class="success-msg" style="display:none;">
|
||||
<h2>Order submitted!</h2>
|
||||
<p id="success-detail"></p>
|
||||
<button class="back-btn" onclick="location.reload()">Back to Menu</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -351,15 +361,34 @@ const quantities = {{}};
|
|||
let formClosed = false;
|
||||
{'const GOOGLE_CLIENT_ID = ' + google_client_id_json + ';\nlet googleCredential = null;\nlet googleUser = null;' if use_google_auth else ''}
|
||||
|
||||
function escapeHtml(str) {{
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}}
|
||||
|
||||
function employeePrice(retail) {{
|
||||
if (!retail) return 0;
|
||||
const bulk = retail * (1 - BULK_DISCOUNT / 100);
|
||||
return Math.round(bulk * (1 - COMPANY_SUBSIDY / 100) * 100) / 100;
|
||||
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100) / 100;
|
||||
const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100) / 100;
|
||||
return empPrice;
|
||||
}}
|
||||
{google_auth_js}
|
||||
function checkDuplicateOrder() {{
|
||||
try {{
|
||||
if (localStorage.getItem('lastOrderWeek') === WEEK) {{
|
||||
document.getElementById('duplicate-warning').style.display = 'block';
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
|
||||
function init() {{
|
||||
checkFormStatus();
|
||||
{'waitForGoogleAuth();' if use_google_auth else 'loadRoster();'}
|
||||
{'waitForGoogleAuth();' if use_google_auth else 'loadRoster(); checkDuplicateOrder();'}
|
||||
// Build filter buttons
|
||||
const tags = new Set();
|
||||
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
|
||||
|
|
@ -399,27 +428,29 @@ function renderMeals() {{
|
|||
|
||||
let tagsHtml = '';
|
||||
if (meal.is_new) tagsHtml += '<span class="new">NEW</span>';
|
||||
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{t}}</span>`; }});
|
||||
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{escapeHtml(t)}}</span>`; }});
|
||||
|
||||
const descHtml = meal.description ? `<div class="meal-desc">${{meal.description}}</div>` : '';
|
||||
const descHtml = meal.description ? `<div class="meal-desc">${{escapeHtml(meal.description)}}</div>` : '';
|
||||
const hasDiscount = (BULK_DISCOUNT > 0 || COMPANY_SUBSIDY > 0) && meal.price;
|
||||
const empPrice = employeePrice(meal.price);
|
||||
const priceHtml = hasDiscount
|
||||
? `<span class="price-retail">$${{meal.price.toFixed(2)}}</span><span class="price-employee">$${{empPrice.toFixed(2)}}</span>`
|
||||
: (meal.price ? `$${{meal.price.toFixed(2)}}` : '—');
|
||||
|
||||
const safeName = escapeHtml(meal.name);
|
||||
const safeImageUrl = escapeHtml(meal.image_url);
|
||||
card.innerHTML = `
|
||||
${{meal.image_url ? `<img class="meal-img" src="${{meal.image_url}}" alt="${{meal.name}}" loading="lazy">` : ''}}
|
||||
${{meal.image_url ? `<img class="meal-img" src="${{safeImageUrl}}" alt="${{safeName}}" loading="lazy">` : ''}}
|
||||
<div class="meal-info">
|
||||
<div class="meal-name">${{meal.name}}</div>
|
||||
<div class="meal-name">${{safeName}}</div>
|
||||
${{descHtml}}
|
||||
<div class="meal-meta">${{priceHtml}} · ${{meal.calories || '?'}} cal · ${{meal.protein || '?'}} protein</div>
|
||||
<div class="meal-meta">${{priceHtml}} · ${{escapeHtml(meal.calories || '?')}} cal · ${{escapeHtml(meal.protein || '?')}} protein</div>
|
||||
<div class="meal-tags">${{tagsHtml}}</div>
|
||||
</div>
|
||||
<div class="qty-control">
|
||||
<button onclick="changeQty(${{i}}, -1)">−</button>
|
||||
<button onclick="changeQty(${{i}}, -1)" ${{formClosed ? 'disabled' : ''}}>−</button>
|
||||
<input class="qty" type="text" value="${{qty}}" readonly>
|
||||
<button onclick="changeQty(${{i}}, 1)">+</button>
|
||||
<button onclick="changeQty(${{i}}, 1)" ${{formClosed ? 'disabled' : ''}}>+</button>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
|
|
@ -448,7 +479,7 @@ function updateTotal() {{
|
|||
|
||||
{submit_order_js}
|
||||
|
||||
async function loadRoster() {{
|
||||
{"" if use_google_auth else """async function loadRoster() {{
|
||||
try {{
|
||||
const res = await fetch(ROSTER_URL);
|
||||
const data = await res.json();
|
||||
|
|
@ -477,7 +508,7 @@ async function loadRoster() {{
|
|||
}});
|
||||
}}
|
||||
}}
|
||||
|
||||
"""}
|
||||
async function checkFormStatus() {{
|
||||
if (!STATUS_URL) return;
|
||||
try {{
|
||||
|
|
@ -486,31 +517,45 @@ async function checkFormStatus() {{
|
|||
if (data.status === 'closed') {{
|
||||
formClosed = true;
|
||||
document.getElementById('closed-overlay').style.display = 'flex';
|
||||
startCountdown();
|
||||
document.querySelectorAll('.qty-control button').forEach(b => b.disabled = true);
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
startCountdown(data);
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
|
||||
function startCountdown() {{
|
||||
let reopenAtMs = null;
|
||||
|
||||
function startCountdown(data) {{
|
||||
if (data && data.reopen_at) {{
|
||||
reopenAtMs = data.reopen_at * 1000;
|
||||
}}
|
||||
updateCountdown();
|
||||
setInterval(updateCountdown, 1000);
|
||||
}}
|
||||
|
||||
function updateCountdown() {{
|
||||
const now = new Date();
|
||||
const etStr = now.toLocaleString('en-US', {{ timeZone: 'America/New_York', hour12: false }});
|
||||
const etDate = new Date(etStr);
|
||||
const day = etDate.getDay();
|
||||
const hour = etDate.getHours();
|
||||
let target;
|
||||
|
||||
let daysUntil = (1 - day + 7) % 7;
|
||||
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
|
||||
if (reopenAtMs) {{
|
||||
target = new Date(reopenAtMs);
|
||||
}} else {{
|
||||
const etStr = now.toLocaleString('en-US', {{ timeZone: 'America/New_York', hour12: false }});
|
||||
const etDate = new Date(etStr);
|
||||
const day = etDate.getDay();
|
||||
const hour = etDate.getHours();
|
||||
|
||||
const target = new Date(etDate);
|
||||
target.setDate(target.getDate() + daysUntil);
|
||||
target.setHours(8, 0, 0, 0);
|
||||
let daysUntil = (1 - day + 7) % 7;
|
||||
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
|
||||
|
||||
let diff = Math.max(0, Math.floor((target - etDate) / 1000));
|
||||
target = new Date(etDate);
|
||||
target.setDate(target.getDate() + daysUntil);
|
||||
target.setHours(8, 0, 0, 0);
|
||||
}}
|
||||
|
||||
let diff = Math.max(0, Math.floor((target - now) / 1000));
|
||||
const d = Math.floor(diff / 86400); diff %= 86400;
|
||||
const h = Math.floor(diff / 3600); diff %= 3600;
|
||||
const m = Math.floor(diff / 60);
|
||||
|
|
@ -570,6 +615,7 @@ def main():
|
|||
google_client_id = args.google_client_id or config.get("google_client_id", "")
|
||||
if not google_client_id:
|
||||
try:
|
||||
import boto3
|
||||
ssm = boto3.client("ssm")
|
||||
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
|
||||
google_client_id = resp["Parameter"]["Value"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue