import { and, asc, eq, inArray, isNull } from 'drizzle-orm' import { Hono, type Context } from 'hono' import { bodyLimit } from 'hono/body-limit' import { randomBytes } from 'node:crypto' import { z } from 'zod' import type { Kiosk, KioskConfig } from '../../shared/api-types.ts' import { incidentReference } from '../../shared/format.ts' import { createKioskSchema, kioskReportSchema, updateKioskSchema } from '../../shared/schemas.ts' import { requireRole } from '../auth/middleware.ts' import { RateLimiter } from '../auth/rate-limit.ts' import type { Db } from '../db/client.ts' import { areas, kiosks, organizations, sites } from '../db/schema.ts' import { saveAttachment } from '../lib/attachments.ts' import { audit, diff } from '../lib/audit.ts' import { iso } from '../lib/dates.ts' import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts' import { auditIncident } from '../lib/incidents.ts' import { fileReport } from '../lib/reporting.ts' export const KIOSK_HEADER = 'x-kiosk-token' const MAX_FILES_PER_REPORT = 5 const idParam = z.object({ id: z.uuid() }) async function toKiosk(db: Db, row: typeof kiosks.$inferSelect): Promise { const [site] = row.siteId ? await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.id, row.siteId)) : [] return { id: row.id, name: row.name, token: row.token, site: site ?? null, lastUsedAt: iso(row.lastUsedAt), revokedAt: iso(row.revokedAt), createdAt: iso(row.createdAt) } } async function assertSite(db: Db, organizationId: string, siteId: string | null | undefined) { if (!siteId) return const [site] = await db.select({ id: sites.id }).from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId))) if (!site) throw fail(400, 'Unknown site') } /** Managing reporting links (signed-in safety managers). */ export const kioskAdminRoutes = new Hono() .get('/', requireRole('safety_manager'), async (c) => { const me = currentUser(c) const db = c.get('db') const rows = await db.select().from(kiosks).where(eq(kiosks.organizationId, me.organizationId)).orderBy(asc(kiosks.name)) return c.json({ kiosks: await Promise.all(rows.map((row) => toKiosk(db, row))) }) }) .post('/', requireRole('safety_manager'), validate('json', createKioskSchema), async (c) => { const me = currentUser(c) const db = c.get('db') const input = c.req.valid('json') await assertSite(db, me.organizationId, input.siteId) const [row] = await db .insert(kiosks) .values({ ...input, organizationId: me.organizationId, token: randomBytes(24).toString('base64url'), createdById: me.id }) .returning() await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'create', changes: { name: row.name } }) return c.json({ kiosk: await toKiosk(db, row) }, 201) }) .patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateKioskSchema), async (c) => { const me = currentUser(c) const db = c.get('db') const [current] = await db .select() .from(kiosks) .where(and(eq(kiosks.id, c.req.valid('param').id), eq(kiosks.organizationId, me.organizationId))) if (!current) throw fail(404, 'Reporting link not found') const { revoked, ...fields } = c.req.valid('json') await assertSite(db, me.organizationId, fields.siteId) const update = { ...fields, revokedAt: revoked === undefined ? undefined : revoked ? (current.revokedAt ?? new Date()) : null } const changes = diff(current, update) if (Object.keys(changes).length === 0) return c.json({ kiosk: await toKiosk(db, current) }) const [row] = await db.update(kiosks).set(update).where(eq(kiosks.id, current.id)).returning() await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'update', changes }) return c.json({ kiosk: await toKiosk(db, row) }) }) /** * Endpoints used with a reporting link's token instead of a session. The token travels in a custom * header, which browsers never send cross-site without a CORS preflight, so these can't be forged * from another site. */ export function kioskPublicRoutes() { // Each link can file 30 reports per 10 minutes: plenty for a busy shop floor, a brake on abuse. const limiter = new RateLimiter(30, 10 * 60 * 1000) async function kioskFrom(c: Context) { const token = c.req.header(KIOSK_HEADER) if (!token) throw fail(401, 'Missing reporting link token') const [row] = await c .get('db') .select() .from(kiosks) .where(and(eq(kiosks.token, token), isNull(kiosks.revokedAt))) if (!row) throw fail(401, 'This reporting link is no longer active. Ask your safety team for a new one.') return row } return new Hono() .get('/config', async (c) => { const kiosk = await kioskFrom(c) const db = c.get('db') const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, kiosk.organizationId)) const siteRows = await db .select() .from(sites) .where(and(eq(sites.organizationId, kiosk.organizationId), isNull(sites.archivedAt), kiosk.siteId ? eq(sites.id, kiosk.siteId) : undefined)) .orderBy(asc(sites.name)) const areaRows = siteRows.length ? await db .select() .from(areas) .where(and(inArray(areas.siteId, siteRows.map((s) => s.id)), isNull(areas.archivedAt))) .orderBy(asc(areas.name)) : [] const config: KioskConfig = { organization, kiosk: { name: kiosk.name }, sites: siteRows.map((site) => ({ id: site.id, name: site.name, address: null, timezone: site.timezone, archivedAt: null, areas: areaRows .filter((area) => area.siteId === site.id) .map((area) => ({ id: area.id, siteId: area.siteId, parentId: area.parentId, name: area.name, kind: area.kind, archivedAt: null })), })), } return c.json(config) }) // Multipart: a `report` field holding the JSON report, plus up to 5 `files`. .post('/reports', async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes * MAX_FILES_PER_REPORT + 256 * 1024, onError: (c) => c.json({ error: 'Files are too large' }, 413) })(c, next), async (c) => { const kiosk = await kioskFrom(c) if (!limiter.attempt(kiosk.id)) throw fail(429, 'Too many reports from this link right now. Please try again in a few minutes.') const db = c.get('db') const body = await c.req.parseBody({ all: true }) let json: unknown try { json = JSON.parse(String(body.report ?? '')) } catch { throw fail(400, 'Missing report') } const parsed = kioskReportSchema.safeParse(json) if (!parsed.success) { return c.json({ error: 'Invalid request', issues: parsed.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400) } if (kiosk.siteId && parsed.data.siteId !== kiosk.siteId) throw fail(400, 'This reporting link is for a different site') const files = ([] as unknown[]).concat(body.files ?? []).filter((file) => file instanceof File) if (files.length > MAX_FILES_PER_REPORT) throw fail(400, `Attach at most ${MAX_FILES_PER_REPORT} photos`) const { reporterName, ...report } = parsed.data const { id, number, created } = await fileReport(db, c.get('notifier'), { ...report, organizationId: kiosk.organizationId, reporter: null, reporterName, anonymous: !reporterName, kioskId: kiosk.id, }) await db.update(kiosks).set({ lastUsedAt: new Date() }).where(eq(kiosks.id, kiosk.id)) // Files are only taken with the first copy of a submission, so retries don't duplicate photos. const failed: string[] = [] if (created) { for (const file of files) { try { const row = await saveAttachment({ db, storage: c.get('storage'), config: c.get('config') }, id, file, null) await auditIncident(db, null, kiosk.organizationId, id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName }) } catch (error) { failed.push(`${(file as File).name}: ${error instanceof Error ? error.message : 'upload failed'}`) } } } // Only the reference goes back: people using a link can't read reports. return c.json({ reference: incidentReference(number), failedFiles: failed }, created ? 201 : 200) }) }