diff --git a/src/config/database.js b/src/config/database.js index 5668f26..639ab0f 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -259,7 +259,8 @@ const ready = new Promise(resolve => { _readyResolve = resolve; }); 'ALTER TABLE users ADD COLUMN is_organizer INTEGER DEFAULT 0', 'ALTER TABLE hunts ADD COLUMN start_date DATETIME', 'ALTER TABLE hunts ADD COLUMN hidden_until_start INTEGER DEFAULT 0', - 'ALTER TABLE users ADD COLUMN display_name TEXT' + 'ALTER TABLE users ADD COLUMN display_name TEXT', + 'ALTER TABLE complaint_reports ADD COLUMN reporter_ip TEXT' ]; for (const m of migrations) { try { _db.run(m); } catch (e) { /* column already exists */ } diff --git a/src/models/index.js b/src/models/index.js index f728117..0088b0f 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -536,12 +536,31 @@ const OrganizerApplications = { // ─── Complaint Reports ─────────────────────────────────── const ComplaintReports = { - submit({ huntId, packageId, reportedByUserId, reporterName, reporterContact, message }) { + submit({ huntId, packageId, reportedByUserId, reporterName, reporterContact, message, reporterIp }) { return db.prepare(` INSERT INTO complaint_reports - (hunt_id, package_id, reported_by_user_id, reporter_name, reporter_contact, message) - VALUES (?, ?, ?, ?, ?, ?) - `).run(huntId, packageId, reportedByUserId || null, reporterName || null, reporterContact || null, message).lastInsertRowid; + (hunt_id, package_id, reported_by_user_id, reporter_name, reporter_contact, message, reporter_ip) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(huntId, packageId, reportedByUserId || null, reporterName || null, reporterContact || null, message, reporterIp || null).lastInsertRowid; + }, + + hasOpenFromIp(packageId, ip) { + if (!ip) return false; + const row = db.prepare(` + SELECT id FROM complaint_reports + WHERE package_id = ? AND reporter_ip = ? AND status = 'open' + LIMIT 1 + `).get(packageId, ip); + return !!row; + }, + + countRecentFromIp(ip, windowMinutes = 60) { + if (!ip) return 0; + const row = db.prepare(` + SELECT COUNT(*) as cnt FROM complaint_reports + WHERE reporter_ip = ? AND created_at > datetime('now', ? || ' minutes') + `).get(ip, `-${windowMinutes}`); + return row ? row.cnt : 0; }, getOpenByHunt(huntId) { diff --git a/src/routes/hunts.js b/src/routes/hunts.js index 7ad8e11..5d7e524 100644 --- a/src/routes/hunts.js +++ b/src/routes/hunts.js @@ -111,6 +111,19 @@ router.post('/hunt/:shortName/:cardNumber/report', (req, res) => { const reporterName = (req.body.reporter_name || '').trim(); const reporterContact = (req.body.reporter_contact || '').trim(); const message = (req.body.message || '').trim(); + const reporterIp = req.ip || null; + + // Rate limit: max 5 reports per IP per hour + if (reporterIp && ComplaintReports.countRecentFromIp(reporterIp, 60) >= 5) { + req.session.flash = { type: 'danger', message: 'Too many reports submitted recently. Please wait a while before trying again.' }; + return res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}/report`); + } + + // Duplicate suppression: same IP already has an open complaint for this package + if (reporterIp && ComplaintReports.hasOpenFromIp(pkg.id, reporterIp)) { + req.session.flash = { type: 'warning', message: 'You\'ve already submitted a report for this item and it\'s still being reviewed.' }; + return res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}`); + } if (!message || message.length < 10) { req.session.flash = { type: 'danger', message: 'Please include a short description (at least 10 characters).' }; @@ -127,6 +140,7 @@ router.post('/hunt/:shortName/:cardNumber/report', (req, res) => { reportedByUserId: req.session && req.session.userId ? req.session.userId : null, reporterName: reporterName || null, reporterContact: reporterContact || null, + reporterIp, message });