diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cb61647 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: + - main + - fix/backend + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: Backend tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm install + + - name: Syntax check + run: npm run check + + - name: Run tests + run: npm test diff --git a/BACKEND.md b/BACKEND.md index 33a2656..4ae67c7 100644 --- a/BACKEND.md +++ b/BACKEND.md @@ -23,3 +23,39 @@ Open in the browser. The server also serves t Queue and notification data is stored in `data/store.json`. The admin page uses the API when served by Node and falls back to its demo behavior when opened directly as a file. The current delivery channel is browser notifications through the live event stream. Email, WhatsApp, SMS, or push-provider delivery requires provider credentials and can be added inside `createNotification` in `server.js`. + + +## Environment configuration + +For production, configure: + +```text +PORT=3000 +JWT_SECRET= +ADMIN_USERNAME= +ADMIN_PASSWORD= +``` + +Admin-protected endpoints require a Bearer JWT obtained from `POST /api/auth/login`: + +- `PATCH /api/tokens/:id/status` +- `POST /api/notifications` + +Public endpoints include health, readiness, queue reads, token creation, and SSE subscription. + +## Testing and CI + +Run locally: + +```sh +npm run check +npm test +``` + +GitHub Actions runs the same checks on pushes to `main` and `fix/backend`, and on pull requests targeting `main`. + +## Persistence roadmap + +The prototype currently uses `data/store.json`. Writes are performed through a temporary file and atomic rename to reduce partial-write corruption. + +For production migration, use PostgreSQL with transactions and database constraints. The target schema is in `docs/postgresql-schema.sql`. A database sequence/transaction should replace application-side token-number generation to prevent concurrent requests from producing the same number. diff --git a/assets/js/admin/queue.js b/assets/js/admin/queue.js index 3675982..e435013 100644 --- a/assets/js/admin/queue.js +++ b/assets/js/admin/queue.js @@ -24,7 +24,12 @@ export async function updateStatus(id, newStatus) { if (state.backendAvailable) { const response = await fetch(`/api/tokens/${encodeURIComponent(id)}/status`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(sessionStorage.getItem('digitoken_admin_token') + ? { Authorization: `Bearer ${sessionStorage.getItem('digitoken_admin_token')}` } + : {}) + }, body: JSON.stringify({ status: newStatus, channel: 'browser' }) }); if (!response.ok) return alert('Unable to update this token.'); diff --git a/auth.html b/auth.html index 3c4b2eb..ece335b 100644 --- a/auth.html +++ b/auth.html @@ -186,13 +186,33 @@

Verify OTP

googleBtn.style.display = role === 'admin' ? 'none' : 'block'; } - function handleLogin(e) { + async function handleLogin(e) { e.preventDefault(); const role = document.querySelector('input[name="role"]:checked').value; - if (role === 'admin') { - window.location.href = 'admin.html'; - } else { + if (role !== 'admin') { window.location.href = 'dashboard.html'; + return; + } + + const inputs = document.querySelectorAll('#view-login input'); + const username = inputs[1]?.value?.trim(); + const password = inputs[2]?.value || ''; + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Admin login failed.'); + return; + } + sessionStorage.setItem('digitoken_admin_token', data.token); + window.location.href = 'admin.html'; + } catch { + alert('Unable to reach the DigiToken API.'); } } diff --git a/docs/postgresql-schema.sql b/docs/postgresql-schema.sql new file mode 100644 index 0000000..52424b7 --- /dev/null +++ b/docs/postgresql-schema.sql @@ -0,0 +1,22 @@ +CREATE TABLE tokens ( + id BIGSERIAL PRIMARY KEY, + public_id VARCHAR(20) NOT NULL UNIQUE, + type VARCHAR(10) NOT NULL CHECK (type IN ('walkin', 'online')), + service VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'waiting' + CHECK (status IN ('waiting', 'serving', 'completed', 'no-show')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_tokens_status_created_at ON tokens(status, created_at); + +CREATE TABLE notifications ( + id UUID PRIMARY KEY, + token_id BIGINT REFERENCES tokens(id) ON DELETE SET NULL, + channel VARCHAR(20) NOT NULL DEFAULT 'browser', + message VARCHAR(500) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'delivered', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_notifications_created_at ON notifications(created_at DESC); diff --git a/package.json b/package.json index 3105344..516fe82 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "DigiToken queue maintenance app", "scripts": { "start": "node server.js", - "check": "node --check server.js" + "check": "node --check server.js", + "test": "node --test" } } diff --git a/server.js b/server.js index e4e6f82..4260201 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,199 @@ const fs = require('node:fs/promises'); const path = require('node:path'); const crypto = require('node:crypto'); +const PORT = Number(process.env.PORT) || 3000; +const ROOT = __dirname; +const JWT_SECRET = process.env.JWT_SECRET || ''; +const ADMIN_USERNAME = process.env.ADMIN_USERNAME || ''; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; +const RATE_LIMIT_WINDOW_MS = 60 * 1000; +const RATE_LIMIT_MAX = 60; +const rateLimitStore = new Map(); +const STORE_PATH = process.env.STORE_PATH || path.join(ROOT, 'data', 'store.json'); +const clients = new Set(); + +const MIME_TYPES = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png' +}; + +async function readStore() { + const raw = await fs.readFile(STORE_PATH, 'utf8'); + const store = JSON.parse(raw); + if (!store || !Array.isArray(store.tokens) || !Array.isArray(store.notifications)) { + throw new Error('store has an invalid structure'); + } + return store; +} + +async function writeStore(store) { + const tempPath = `${STORE_PATH}.${process.pid}.tmp`; + await fs.writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); + await fs.rename(tempPath, STORE_PATH); +} + +function sendJson(response, status, payload) { + response.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'no-referrer' + }); + response.end(JSON.stringify(payload)); +} + +function rateLimit(request, response) { + const now = Date.now(); + const key = request.socket.remoteAddress || 'unknown'; + const current = rateLimitStore.get(key); + + if (!current || now - current.startedAt >= RATE_LIMIT_WINDOW_MS) { + rateLimitStore.set(key, { startedAt: now, count: 1 }); + return true; + } + + current.count += 1; + if (current.count > RATE_LIMIT_MAX) { + response.setHeader('Retry-After', '60'); + sendJson(response, 429, { error: 'too many requests' }); + return false; + } + + return true; +} + +function base64Url(value) { + return Buffer.from(value).toString('base64url'); +} + +function createJwt(payload) { + const header = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = base64Url(JSON.stringify(payload)); + const signature = crypto + .createHmac('sha256', JWT_SECRET) + .update(`${header}.${body}`) + .digest('base64url'); + return `${header}.${body}.${signature}`; +} + +function verifyJwt(token) { + if (!JWT_SECRET || typeof token !== 'string') return null; + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const [header, body, signature] = parts; + const expected = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url'); + if (signature.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null; + + try { + const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); + if (!payload.exp || payload.exp <= Math.floor(Date.now() / 1000)) return null; + return payload; + } catch { + return null; + } +} + +function requireRole(request, response, role) { + const header = request.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + const payload = verifyJwt(token); + if (!payload || payload.role !== role) { + sendJson(response, 401, { error: 'authentication required' }); + return null; + } + return payload; +} + +function broadcast(event, payload) { + const message = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; + clients.forEach(client => client.write(message)); +} + +const MAX_BODY_SIZE = 64 * 1024; +const TOKEN_TYPES = new Set(['walkin', 'online']); +const NOTIFICATION_CHANNELS = new Set(['browser', 'email', 'sms']); +const TOKEN_STATUSES = new Set(['waiting', 'serving', 'completed', 'no-show']); + +async function readBody(request) { + let body = ''; + for await (const chunk of request) { + body += chunk; + if (Buffer.byteLength(body, 'utf8') > MAX_BODY_SIZE) return null; + } + if (!body) return {}; + try { + const parsed = JSON.parse(body); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function validateTokenInput(body) { + if (!body || typeof body !== 'object') return 'request body must be a JSON object'; + if (!TOKEN_TYPES.has(body.type)) return 'type must be walkin or online'; + if (typeof body.service !== 'string' || !body.service.trim()) return 'service is required'; + if (body.service.trim().length > 100) return 'service must be 100 characters or fewer'; + if (body.channel !== undefined && !NOTIFICATION_CHANNELS.has(body.channel)) { + return 'channel must be browser, email, or sms'; + } + return null; +} + +function validateStatusInput(body) { + if (!body || typeof body !== 'object') return 'request body must be a JSON object'; + if (!TOKEN_STATUSES.has(body.status)) return 'invalid status'; + if (body.channel !== undefined && !NOTIFICATION_CHANNELS.has(body.channel)) { + return 'channel must be browser, email, or sms'; + } + return null; +} + +function validateNotificationInput(body) { + if (!body || typeof body !== 'object') return 'request body must be a JSON object'; + if (typeof body.message !== 'string' || !body.message.trim()) return 'message is required'; + if (body.message.trim().length > 500) return 'message must be 500 characters or fewer'; + if (body.tokenId !== undefined && body.tokenId !== null && typeof body.tokenId !== 'string') { + return 'tokenId must be a string'; + } + if (body.channel !== undefined && !NOTIFICATION_CHANNELS.has(body.channel)) { + return 'channel must be browser, email, or sms'; + } + return null; +} + +function notificationFrom(tokenId, message, channel = 'browser') { + return { + id: crypto.randomUUID(), + tokenId, + channel, + message, + status: 'delivered', + createdAt: new Date().toISOString() + }; +} + +async function createNotification(store, tokenId, message, channel) { + const notification = notificationFrom(tokenId, message, channel); + store.notifications.unshift(notification); + store.notifications = store.notifications.slice(0, 100); + await writeStore(store); + broadcast('notification', notification); + return notification; +} + +function getNextTokenSequence(tokens, prefix) { + return tokens.reduce((highest, token) => { + if (typeof token.id !== 'string') return highest; + const match = token.id.match(new RegExp(`^${prefix}-(\\d+)const http = require('node:http'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); + const PORT = Number(process.env.PORT) || 3000; const ROOT = __dirname; const STORE_PATH = path.join(ROOT, 'data', 'store.json'); @@ -34,17 +227,59 @@ function broadcast(event, payload) { clients.forEach(client => client.write(message)); } +const MAX_BODY_SIZE = 64 * 1024; +const TOKEN_TYPES = new Set(['walkin', 'online']); +const NOTIFICATION_CHANNELS = new Set(['browser', 'email', 'sms']); +const TOKEN_STATUSES = new Set(['waiting', 'serving', 'completed', 'no-show']); + async function readBody(request) { let body = ''; - for await (const chunk of request) body += chunk; + for await (const chunk of request) { + body += chunk; + if (Buffer.byteLength(body, 'utf8') > MAX_BODY_SIZE) return null; + } if (!body) return {}; try { - return JSON.parse(body); + const parsed = JSON.parse(body); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; } catch { return null; } } +function validateTokenInput(body) { + if (!body || typeof body !== 'object') return 'request body must be a JSON object'; + if (!TOKEN_TYPES.has(body.type)) return 'type must be walkin or online'; + if (typeof body.service !== 'string' || !body.service.trim()) return 'service is required'; + if (body.service.trim().length > 100) return 'service must be 100 characters or fewer'; + if (body.channel !== undefined && !NOTIFICATION_CHANNELS.has(body.channel)) { + return 'channel must be browser, email, or sms'; + } + return null; +} + +function validateStatusInput(body) { + if (!body || typeof body !== 'object') return 'request body must be a JSON object'; + if (!TOKEN_STATUSES.has(body.status)) return 'invalid status'; + if (body.channel !== undefined && !NOTIFICATION_CHANNELS.has(body.channel)) { + return 'channel must be browser, email, or sms'; + } + return null; +} + +function validateNotificationInput(body) { + if (!body || typeof body !== 'object') return 'request body must be a JSON object'; + if (typeof body.message !== 'string' || !body.message.trim()) return 'message is required'; + if (body.message.trim().length > 500) return 'message must be 500 characters or fewer'; + if (body.tokenId !== undefined && body.tokenId !== null && typeof body.tokenId !== 'string') { + return 'tokenId must be a string'; + } + if (body.channel !== undefined && !NOTIFICATION_CHANNELS.has(body.channel)) { + return 'channel must be browser, email, or sms'; + } + return null; +} + function notificationFrom(tokenId, message, channel = 'browser') { return { id: crypto.randomUUID(), @@ -65,13 +300,45 @@ async function createNotification(store, tokenId, message, channel) { return notification; } +)); + if (!match) return highest; + return Math.max(highest, Number(match[1])); + }, 0) + 1; +} + async function handleApi(request, response, pathname) { + if (!rateLimit(request, response)) return; const store = await readStore(); + if (request.method === 'POST' && pathname === '/api/auth/login') { + const body = await readBody(request); + if (!JWT_SECRET || !ADMIN_USERNAME || !ADMIN_PASSWORD) { + return sendJson(response, 503, { error: 'authentication is not configured' }); + } + if (!body || body.username !== ADMIN_USERNAME || body.password !== ADMIN_PASSWORD) { + return sendJson(response, 401, { error: 'invalid credentials' }); + } + const token = createJwt({ + sub: ADMIN_USERNAME, + role: 'admin', + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 8 + }); + return sendJson(response, 200, { token, tokenType: 'Bearer', expiresIn: 28800 }); + } + if (request.method === 'GET' && pathname === '/api/health') { return sendJson(response, 200, { ok: true, service: 'DigiToken API' }); } + if (request.method === 'GET' && pathname === '/api/ready') { + try { + await readStore(); + return sendJson(response, 200, { ready: true }); + } catch { + return sendJson(response, 503, { ready: false }); + } + } + if (request.method === 'GET' && pathname === '/api/queue') { return sendJson(response, 200, { tokens: store.tokens }); } @@ -94,14 +361,15 @@ async function handleApi(request, response, pathname) { if (request.method === 'POST' && pathname === '/api/tokens') { const body = await readBody(request); - if (!body || !body.service) return sendJson(response, 400, { error: 'service is required' }); + const validationError = validateTokenInput(body); + if (validationError) return sendJson(response, 400, { error: validationError }); const prefix = body.type === 'walkin' ? 'W' : 'A'; - const sequence = store.tokens.filter(token => token.id.startsWith(`${prefix}-`)).length + 1; + const sequence = getNextTokenSequence(store.tokens, prefix); const token = { id: `${prefix}-${String(sequence).padStart(3, '0')}`, type: body.type === 'walkin' ? 'walkin' : 'online', - service: String(body.service), + service: body.service.trim(), status: 'waiting', time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }; @@ -114,12 +382,13 @@ async function handleApi(request, response, pathname) { const statusMatch = pathname.match(/^\/api\/tokens\/([^/]+)\/status$/); if (request.method === 'PATCH' && statusMatch) { + if (!requireRole(request, response, 'admin')) return; const body = await readBody(request); + const validationError = validateStatusInput(body); + if (validationError) return sendJson(response, 400, { error: validationError }); + const token = store.tokens.find(item => item.id === decodeURIComponent(statusMatch[1])); if (!token) return sendJson(response, 404, { error: 'token not found' }); - if (!body || !['waiting', 'serving', 'completed', 'no-show'].includes(body.status)) { - return sendJson(response, 400, { error: 'invalid status' }); - } if (body.status === 'serving') { store.tokens.forEach(item => { @@ -138,9 +407,11 @@ async function handleApi(request, response, pathname) { } if (request.method === 'POST' && pathname === '/api/notifications') { + if (!requireRole(request, response, 'admin')) return; const body = await readBody(request); - if (!body || !body.message) return sendJson(response, 400, { error: 'message is required' }); - const notification = await createNotification(store, body.tokenId || null, String(body.message), body.channel); + const validationError = validateNotificationInput(body); + if (validationError) return sendJson(response, 400, { error: validationError }); + const notification = await createNotification(store, body.tokenId || null, body.message.trim(), body.channel); return sendJson(response, 201, notification); } @@ -175,6 +446,15 @@ const server = http.createServer(async (request, response) => { } }); -server.listen(PORT, () => { - console.log(`DigiToken running at http://localhost:${PORT}`); -}); +if (require.main === module) { + server.listen(PORT, () => { + console.log(`DigiToken running at http://localhost:${PORT}`); + }); +} + +module.exports = { + getNextTokenSequence, + validateTokenInput, + validateStatusInput, + validateNotificationInput +}; diff --git a/server.test.js b/server.test.js new file mode 100644 index 0000000..7977863 --- /dev/null +++ b/server.test.js @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + getNextTokenSequence, + validateTokenInput, + validateStatusInput, + validateNotificationInput +} = require('./server'); + +test('token sequence uses the highest existing number', () => { + const tokens = [ + { id: 'W-001' }, + { id: 'W-005' }, + { id: 'W-003' }, + { id: 'A-009' } + ]; + + assert.equal(getNextTokenSequence(tokens, 'W'), 6); + assert.equal(getNextTokenSequence(tokens, 'A'), 10); +}); + +test('token sequence ignores malformed IDs', () => { + assert.equal( + getNextTokenSequence([{ id: 'W-test' }, { id: 'W-abc' }], 'W'), + 1 + ); +}); + +test('token input validation rejects invalid requests', () => { + assert.equal(validateTokenInput(null), 'request body must be a JSON object'); + assert.equal(validateTokenInput({ type: 'invalid', service: 'General' }), 'type must be walkin or online'); + assert.equal(validateTokenInput({ type: 'walkin', service: '' }), 'service is required'); + assert.equal(validateTokenInput({ type: 'walkin', service: 'General', channel: 'fax' }), 'channel must be browser, email, or sms'); + assert.equal(validateTokenInput({ type: 'walkin', service: ' General ', channel: 'browser' }), null); +}); + +test('status validation accepts supported statuses only', () => { + assert.equal(validateStatusInput({ status: 'serving' }), null); + assert.equal(validateStatusInput({ status: 'cancelled' }), 'invalid status'); + assert.equal(validateStatusInput({ status: 'waiting', channel: 'push' }), 'channel must be browser, email, or sms'); +}); + +test('notification validation enforces message and channel rules', () => { + assert.equal(validateNotificationInput({ message: 'Hello' }), null); + assert.equal(validateNotificationInput({ message: '' }), 'message is required'); + assert.equal(validateNotificationInput({ message: 'Hello', channel: 'fax' }), 'channel must be browser, email, or sms'); + assert.equal(validateNotificationInput({ message: 'Hello', tokenId: 42 }), 'tokenId must be a string'); +});