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:
+27
-8
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user