harden: security, UX, and data integrity fixes from deep audit

Extension:
- SQL injection fix: dollar-quoting instead of single-quote escaping
- Cache team.json + resolved member (no re-reads every call)
- System prompt: removed forceful 'Always use member=X', now says auto-detected
- Member name sanitisation: strip non-alphanumeric chars

API (server.js):
- Auth middleware: requireAuth on POST/PATCH/DELETE endpoints (SHIPS_API_KEY env)
- Member normalisation: cleanMember() strips special chars, lowercases
- Case-insensitive queries: LOWER() on all member WHERE/GROUP BY clauses
- XSS prevention: member values sanitised on insert

Frontend (index.html):
- renderFilters: clear old pills before appending (fixes duplication bug)
- filterByMember: hero count stays fixed to total (not filtered count)
- filterByMember: fetches global stats separately for hero + team bar
- filterByMember: loading state (opacity transition during fetch)
- filterByMember: resets project filter when switching members
- renderTeamBar: hidden when ≤1 member (removes redundant UI)
- renderTeamBar: properly manages Everyone pill active state
- renderTeamBar: esc() on member names to prevent XSS
This commit is contained in:
2026-03-10 07:16:15 +08:00
parent 824dedfc3e
commit 53d8324fb4
2 changed files with 60 additions and 24 deletions
+27 -8
View File
@@ -30,6 +30,7 @@
font-size: 0.75rem; font-weight: 600;
}
.lv-team-pill.active .lv-team-count { background: rgba(0,255,159,0.2); }
#timeline, #oops-list { transition: opacity 0.15s ease; }
</style>
</head>
<body>
@@ -280,6 +281,8 @@ function renderFilters(ships){
const counts={};
ships.forEach(s=>{const d=DISPLAY[s.id];const p=d?d.p:detectProject(s.title);counts[p]=(counts[p]||0)+1;});
const container=document.getElementById('filters');
// Remove old project pills (keep the "All" button)
container.querySelectorAll('.lv-pill:not([data-filter="all"])').forEach(b=>b.remove());
['charityright','pnpl','justvitamin','comms','infra','quikcue'].forEach(key=>{
if(!counts[key])return;
const p=PROJECTS[key];
@@ -296,20 +299,31 @@ function filterByMember(member) {
document.querySelectorAll('.lv-team-pill').forEach(b => {
b.classList.toggle('active', b.dataset.member === member);
});
// Re-fetch everything with member filter
// Show loading state
document.getElementById('timeline').style.opacity = '0.4';
document.getElementById('oops-list').style.opacity = '0.4';
// Re-fetch filtered data + always fetch unfiltered stats for hero/team bar
const qs = member === 'all' ? '' : `?member=${encodeURIComponent(member)}`;
Promise.all([
fetch(`${API}/ships${qs}`).then(r => r.json()).catch(() => []),
fetch(`${API}/oops${qs}`).then(r => r.json()).catch(() => []),
fetch(`${API}/stats${qs}`).then(r => r.json()).catch(() => ({ total: 0, shipped: 0, oops: 0 }))
]).then(([ships, oops, stats]) => {
renderHero(stats);
renderStrip(stats, ships);
fetch(`${API}/stats${qs}`).then(r => r.json()).catch(() => ({ total: 0, shipped: 0, oops: 0 })),
fetch(`${API}/stats`).then(r => r.json()).catch(() => null)
]).then(([ships, oops, filteredStats, globalStats]) => {
// Hero always shows total (unfiltered) count
if (globalStats) renderHero(globalStats);
renderStrip(filteredStats, ships);
renderProjects(ships);
renderFilters(ships);
renderTimeline(ships);
renderOops(oops);
renderTeamBar(stats);
// Team bar always uses global stats for member counts
renderTeamBar(globalStats || filteredStats);
// Reset project filter to "all" when switching members
filterTo('all');
// Remove loading state
document.getElementById('timeline').style.opacity = '1';
document.getElementById('oops-list').style.opacity = '1';
});
}
@@ -381,10 +395,15 @@ function titleCase(s){return s.charAt(0).toUpperCase()+s.slice(1);}
function renderTeamBar(stats) {
const bar = document.getElementById('team-bar');
if (!stats.members || !stats.members.length) return;
const section = bar.closest('.lv-team-filter');
if (!stats.members || !stats.members.length) { if(section) section.style.display='none'; return; }
// Hide team bar when only 1 member — it's redundant
if (stats.members.length <= 1) { if(section) section.style.display='none'; return; }
if(section) section.style.display='';
// Clear existing member pills (keep the "Everyone" button)
const allBtn = bar.querySelector('[data-member="all"]');
bar.innerHTML = '';
if (activeMember === 'all') allBtn.classList.add('active'); else allBtn.classList.remove('active');
bar.appendChild(allBtn);
stats.members.forEach(m => {
@@ -392,7 +411,7 @@ function renderTeamBar(stats) {
btn.className = 'lv-team-pill';
if (m.member === activeMember) btn.classList.add('active');
btn.dataset.member = m.member;
btn.innerHTML = `${titleCase(m.member)} <span class="lv-team-count">${m.count}</span>`;
btn.innerHTML = `${esc(titleCase(m.member))} <span class="lv-team-count">${esc(String(m.count))}</span>`;
btn.onclick = () => filterByMember(m.member);
bar.appendChild(btn);
});
+33 -16
View File
@@ -5,6 +5,23 @@ const { Pool } = require('pg');
const app = express();
app.use(express.json());
// ── Simple API key auth for write operations ──────────────────────────────
const API_KEY = process.env.SHIPS_API_KEY || '';
function requireAuth(req, res, next) {
if (!API_KEY) return next(); // No key configured = open (dev mode)
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (token === API_KEY) return next();
res.status(401).json({ error: 'unauthorized' });
}
// ── Member name validation ────────────────────────────────────────────────
function cleanMember(raw) {
if (!raw || typeof raw !== 'string') return 'omair';
// Strip anything that isn't alphanumeric, dash, underscore, or dot
const cleaned = raw.toLowerCase().trim().replace(/[^a-z0-9._-]/g, '');
return cleaned || 'omair';
}
// ── Ships/Oops API (migrated from calvana) ────────────────────────────────
const poolConfig = {
host: process.env.SHIPS_DB_HOST || 'localhost',
@@ -42,7 +59,7 @@ app.get('/api/ships', async (req, res) => {
let query = 'SELECT * FROM ships';
const params = [];
if (member && member !== 'all') {
query += ' WHERE member = $1';
query += ' WHERE LOWER(member) = LOWER($1)';
params.push(member);
}
query += ' ORDER BY created_at DESC';
@@ -60,7 +77,7 @@ app.get('/api/ships/showcase', async (req, res) => {
let query = "SELECT * FROM ships WHERE status = 'shipped'";
const params = [];
if (member && member !== 'all') {
query += ' AND member = $1';
query += ' AND LOWER(member) = LOWER($1)';
params.push(member);
}
query += ' ORDER BY created_at DESC LIMIT 10';
@@ -79,10 +96,10 @@ app.get('/api/stats', async (req, res) => {
const memberFilter = member && member !== 'all';
const params = memberFilter ? [member] : [];
const [shipsTotal, shipsShipped, oopsTotal, members] = await Promise.all([
p.query(`SELECT COUNT(*) FROM ships${memberFilter ? ' WHERE member = $1' : ''}`, params),
p.query(`SELECT COUNT(*) FROM ships WHERE status = 'shipped'${memberFilter ? ' AND member = $1' : ''}`, params),
p.query(`SELECT COUNT(*) FROM oops${memberFilter ? ' WHERE member = $1' : ''}`, params),
p.query("SELECT COALESCE(member, 'omair') as member, COUNT(*) as count FROM ships GROUP BY member ORDER BY count DESC"),
p.query(`SELECT COUNT(*) FROM ships${memberFilter ? ' WHERE LOWER(member) = LOWER($1)' : ''}`, params),
p.query(`SELECT COUNT(*) FROM ships WHERE status = 'shipped'${memberFilter ? ' AND LOWER(member) = LOWER($1)' : ''}`, params),
p.query(`SELECT COUNT(*) FROM oops${memberFilter ? ' WHERE LOWER(member) = LOWER($1)' : ''}`, params),
p.query("SELECT LOWER(COALESCE(member, 'omair')) as member, COUNT(*) as count FROM ships GROUP BY LOWER(COALESCE(member, 'omair')) ORDER BY count DESC"),
]);
res.json({
total: parseInt(shipsTotal.rows[0].count),
@@ -96,13 +113,13 @@ app.get('/api/stats', async (req, res) => {
});
// POST ship
app.post('/api/ships', async (req, res) => {
app.post('/api/ships', requireAuth, async (req, res) => {
const { title, status, metric, details, member } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
try {
const { rows } = await getPool().query(
'INSERT INTO ships (title, status, metric, details, member) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[title, status || 'planned', metric || null, details || null, member || 'omair']
[title, status || 'planned', metric || null, details || null, cleanMember(member)]
);
res.status(201).json(rows[0]);
} catch (e) {
@@ -111,7 +128,7 @@ app.post('/api/ships', async (req, res) => {
});
// PATCH ship
app.patch('/api/ships/:id', async (req, res) => {
app.patch('/api/ships/:id', requireAuth, async (req, res) => {
const { id } = req.params;
const { title, status, metric, details, member } = req.body;
try {
@@ -122,7 +139,7 @@ app.patch('/api/ships/:id', async (req, res) => {
if (status !== undefined) { sets.push(`status=$${i++}`); vals.push(status); }
if (metric !== undefined) { sets.push(`metric=$${i++}`); vals.push(metric); }
if (details !== undefined) { sets.push(`details=$${i++}`); vals.push(details); }
if (member !== undefined) { sets.push(`member=$${i++}`); vals.push(member); }
if (member !== undefined) { sets.push(`member=$${i++}`); vals.push(cleanMember(member)); }
if (sets.length === 0) return res.status(400).json({ error: 'nothing to update' });
sets.push(`updated_at=NOW()`);
vals.push(id);
@@ -138,7 +155,7 @@ app.patch('/api/ships/:id', async (req, res) => {
});
// DELETE ship
app.delete('/api/ships/:id', async (req, res) => {
app.delete('/api/ships/:id', requireAuth, async (req, res) => {
const { id } = req.params;
try {
const { rows } = await getPool().query(
@@ -158,7 +175,7 @@ app.get('/api/oops', async (req, res) => {
let query = 'SELECT * FROM oops';
const params = [];
if (member && member !== 'all') {
query += ' WHERE member = $1';
query += ' WHERE LOWER(member) = LOWER($1)';
params.push(member);
}
query += ' ORDER BY created_at DESC';
@@ -170,13 +187,13 @@ app.get('/api/oops', async (req, res) => {
});
// POST oops
app.post('/api/oops', async (req, res) => {
app.post('/api/oops', requireAuth, async (req, res) => {
const { description, fix_time, commit_link, member } = req.body;
if (!description) return res.status(400).json({ error: 'description required' });
try {
const { rows } = await getPool().query(
'INSERT INTO oops (description, fix_time, commit_link, member) VALUES ($1, $2, $3, $4) RETURNING *',
[description, fix_time || null, commit_link || null, member || 'omair']
[description, fix_time || null, commit_link || null, cleanMember(member)]
);
res.status(201).json(rows[0]);
} catch (e) {
@@ -185,7 +202,7 @@ app.post('/api/oops', async (req, res) => {
});
// DELETE oops
app.delete('/api/oops/:id', async (req, res) => {
app.delete('/api/oops/:id', requireAuth, async (req, res) => {
const { id } = req.params;
try {
const { rows } = await getPool().query(
@@ -202,7 +219,7 @@ app.delete('/api/oops/:id', async (req, res) => {
app.get('/api/team', async (req, res) => {
try {
const { rows } = await getPool().query(
"SELECT COALESCE(member, 'omair') as member, COUNT(*)::int as ships, COUNT(*) FILTER (WHERE status = 'shipped')::int as shipped FROM ships GROUP BY member ORDER BY ships DESC"
"SELECT LOWER(COALESCE(member, 'omair')) as member, COUNT(*)::int as ships, COUNT(*) FILTER (WHERE status = 'shipped')::int as shipped FROM ships GROUP BY LOWER(COALESCE(member, 'omair')) ORDER BY ships DESC"
);
res.json(rows);
} catch (e) {