Files
yolkbook/nginx/html/js/api.js
derekc 59f9685e2b Move JWT from localStorage to HttpOnly cookie; fix CSRF
- JWT stored in HttpOnly, Secure, SameSite=Strict cookie — JS cannot
  read the token at all; SameSite=Strict prevents CSRF without tokens
- Non-sensitive user payload returned in response body and stored in
  localStorage for UI purposes only (not usable for auth)
- Add POST /api/auth/logout endpoint that clears the cookie server-side
- Add SECURE_COOKIES env var (default true) for local HTTP testing
- Extract login.html inline script to login.js (CSP compliance)
- Remove Authorization: Bearer header from API calls; add credentials:
  include so cookies are sent automatically
- CSP script-src includes unsafe-inline to support existing onclick
  handlers throughout the app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:57:22 -07:00

125 lines
4.7 KiB
JavaScript

// api.js — shared fetch helpers and utilities used by every page
const API = {
async _fetch(url, options = {}) {
const headers = { 'Content-Type': 'application/json' };
const res = await fetch(url, { credentials: 'include', headers, ...options });
if (res.status === 401) {
localStorage.removeItem('user');
window.location.href = '/login';
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `Request failed (${res.status})`);
}
if (res.status === 204) return null; // DELETE returns No Content
return res.json();
},
// GET requests retry up to 3 times with exponential backoff on network errors or 5xx.
// Mutating methods (POST/PUT/DELETE) are not retried — they are not idempotent.
async _fetchWithRetry(url, options = {}) {
const isReadOnly = !options.method || options.method === 'GET';
const delays = [500, 1000, 2000];
let lastErr;
const attempts = isReadOnly ? 3 : 1;
for (let i = 0; i < attempts; i++) {
try {
return await API._fetch(url, options);
} catch (err) {
lastErr = err;
// Don't retry client errors (4xx) or if it's the last attempt
const is5xx = err.message.match(/\(5\d\d\)/);
if ((!isReadOnly || (!is5xx && !err.message.includes('Failed to fetch'))) || i === attempts - 1) throw err;
await new Promise(r => setTimeout(r, delays[i]));
}
}
throw lastErr;
},
get: (url) => API._fetchWithRetry(url),
post: (url, data) => API._fetchWithRetry(url, { method: 'POST', body: JSON.stringify(data) }),
put: (url, data) => API._fetchWithRetry(url, { method: 'PUT', body: JSON.stringify(data) }),
del: (url) => API._fetchWithRetry(url, { method: 'DELETE' }),
};
// ── Offline indicator ─────────────────────────────────────────────────────────
function _setOfflineBanner(offline) {
let banner = document.getElementById('offline-banner');
if (offline) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'offline-banner';
banner.textContent = 'You are offline — changes cannot be saved';
document.body.prepend(banner);
}
} else {
if (banner) banner.remove();
}
}
window.addEventListener('online', () => _setOfflineBanner(false));
window.addEventListener('offline', () => _setOfflineBanner(true));
document.addEventListener('DOMContentLoaded', () => _setOfflineBanner(!navigator.onLine));
// Show a timed success or error message inside a .message element
function showMessage(el, text, type = 'success') {
el.textContent = text;
el.className = `message ${type} visible`;
setTimeout(() => { el.className = 'message'; }, 4000);
}
// Set an input[type=date] to today's date in the user's configured timezone
function setToday(inputEl) {
const tz = (typeof Auth !== 'undefined' && Auth.getUser()?.timezone)
|| Intl.DateTimeFormat().resolvedOptions().timeZone
|| 'UTC';
// en-CA locale produces YYYY-MM-DD which is what date inputs expect
inputEl.value = new Date().toLocaleDateString('en-CA', { timeZone: tz });
}
// Format YYYY-MM-DD → MM/DD/YYYY for display
function fmtDate(str) {
if (!str) return '—';
const [y, m, d] = str.split('-');
return `${m}/${d}/${y}`;
}
// Format a number as a dollar amount
function fmtMoney(val) {
if (val == null || val === '' || isNaN(Number(val))) return '—';
return '$' + Number(val).toFixed(2);
}
// Format a small decimal (cost per egg) with 4 decimal places
function fmtMoneyFull(val) {
if (val == null || val === '' || isNaN(Number(val))) return '—';
return '$' + Number(val).toFixed(4);
}
// Escape HTML special characters to prevent XSS when rendering user content
function escHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Highlight the nav link that matches the current page
function highlightNav() {
const path = window.location.pathname.replace(/\.html$/, '').replace(/\/$/, '') || '/';
document.querySelectorAll('.nav-links a').forEach(a => {
const href = a.getAttribute('href').replace(/\/$/, '') || '/';
if (href === path) a.classList.add('active');
});
}
document.addEventListener('DOMContentLoaded', highlightNav);