48c2f81d00
Each QR code gets a /qr/<slug> redirect that logs scans (IP, user agent, timestamp) to disk. The admin QR Codes view lets you add, edit, enable/disable, and delete codes, with per-code scan history inline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
89 lines
3.9 KiB
JavaScript
89 lines
3.9 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
|
import { requireAdminAuth } from '../auth.js'
|
|
import { state } from '../state.js'
|
|
import { queueQrCodesWrite } from '../data.js'
|
|
|
|
const MAX_SCANS = 5000
|
|
|
|
export function register(app) {
|
|
// Public redirect — logs the scan, then redirects to destination
|
|
app.get('/qr/:slug', (req, res) => {
|
|
const { slug } = req.params
|
|
const code = state.qrCodes.find(c => c.slug === slug && c.active !== false)
|
|
if (!code) { res.status(404).send('QR code not found.'); return }
|
|
|
|
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown'
|
|
const scan = {
|
|
id: randomUUID(),
|
|
qrId: code.id,
|
|
slug,
|
|
scannedAt: new Date().toISOString(),
|
|
ip,
|
|
userAgent: req.headers['user-agent'] || '',
|
|
}
|
|
state.qrScans.unshift(scan)
|
|
if (state.qrScans.length > MAX_SCANS) state.qrScans.length = MAX_SCANS
|
|
queueQrCodesWrite()
|
|
|
|
res.redirect(302, code.destination)
|
|
})
|
|
|
|
// ── Admin API ──────────────────────────────────────────────────────────────
|
|
|
|
app.get('/api/admin/qr-codes', requireAdminAuth, (_req, res) => {
|
|
const codesWithCounts = state.qrCodes.map(code => ({
|
|
...code,
|
|
scanCount: state.qrScans.filter(s => s.qrId === code.id).length,
|
|
}))
|
|
res.json({ codes: codesWithCounts, scans: state.qrScans.slice(0, 200) })
|
|
})
|
|
|
|
app.post('/api/admin/qr-codes', requireAdminAuth, (req, res) => {
|
|
const label = typeof req.body?.label === 'string' ? req.body.label.trim().slice(0, 100) : ''
|
|
const slug = typeof req.body?.slug === 'string' ? req.body.slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 80) : ''
|
|
const destination = typeof req.body?.destination === 'string' ? req.body.destination.trim() : ''
|
|
|
|
if (!label) { res.status(400).json({ message: 'Label is required.' }); return }
|
|
if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) { res.status(400).json({ message: 'Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.' }); return }
|
|
if (!destination.startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
|
|
if (state.qrCodes.some(c => c.slug === slug)) { res.status(409).json({ message: 'A QR code with this slug already exists.' }); return }
|
|
|
|
const code = { id: randomUUID(), slug, label, destination, active: true, createdAt: new Date().toISOString() }
|
|
state.qrCodes.push(code)
|
|
queueQrCodesWrite()
|
|
res.json({ ok: true, code })
|
|
})
|
|
|
|
app.patch('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
|
|
const code = state.qrCodes.find(c => c.id === req.params.id)
|
|
if (!code) { res.status(404).json({ message: 'QR code not found.' }); return }
|
|
|
|
if (typeof req.body?.label === 'string') code.label = req.body.label.trim().slice(0, 100)
|
|
if (typeof req.body?.destination === 'string') {
|
|
if (!req.body.destination.trim().startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
|
|
code.destination = req.body.destination.trim()
|
|
}
|
|
if (typeof req.body?.active === 'boolean') code.active = req.body.active
|
|
|
|
queueQrCodesWrite()
|
|
res.json({ ok: true, code })
|
|
})
|
|
|
|
app.delete('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
|
|
const idx = state.qrCodes.findIndex(c => c.id === req.params.id)
|
|
if (idx === -1) { res.status(404).json({ message: 'QR code not found.' }); return }
|
|
|
|
const { id } = state.qrCodes[idx]
|
|
state.qrCodes.splice(idx, 1)
|
|
state.qrScans = state.qrScans.filter(s => s.qrId !== id)
|
|
queueQrCodesWrite()
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
app.delete('/api/admin/qr-codes/:id/scans', requireAdminAuth, (req, res) => {
|
|
state.qrScans = state.qrScans.filter(s => s.qrId !== req.params.id)
|
|
queueQrCodesWrite()
|
|
res.json({ ok: true })
|
|
})
|
|
}
|