import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; import rateLimit from '@fastify/rate-limit'; import { LabelDb, LABEL_KINDS, isLabelKind, isAreaKind, AREA_KINDS, type ZoneLabel, type ParkingArea, } from './db.js'; import { AbuseGuard, safeEqual } from './auth.js'; export interface BuildOptions { adminToken: string; /** File path for the SQLite DB, or ':memory:' (tests). Ignored if `db` is given. */ dbPath?: string; db?: LabelDb; trustProxy?: boolean; rateLimitMax?: number; rateLimitWindow?: string; writeRateLimitMax?: number; authFailLimit?: number; authFailWindowMs?: number; blockCooldownMs?: number; seedBlockedIps?: string[]; } interface ZoneParams { zoneId: string; } interface PutBody { kind?: unknown; zoneName?: unknown; customerId?: unknown; note?: unknown; } const str = (v: unknown): string | null => (v == null ? null : String(v)); export async function buildApp(opts: BuildOptions) { const db = opts.db ?? new LabelDb(opts.dbPath ?? ':memory:'); if (opts.seedBlockedIps?.length) db.seedBlocked(opts.seedBlockedIps); const guard = new AbuseGuard( opts.authFailLimit ?? 8, opts.authFailWindowMs ?? 15 * 60 * 1000, opts.blockCooldownMs ?? 60 * 60 * 1000, ); // 1 MB: zone-sync batches carry full Zone objects (policies, logos, …). const app = Fastify({ trustProxy: opts.trustProxy ?? true, logger: false, bodyLimit: 1024 * 1024 }); await app.register(rateLimit, { max: opts.rateLimitMax ?? 120, timeWindow: opts.rateLimitWindow ?? '1 minute', }); // Reject blocked IPs (manual denylist + auto-block) before anything else. app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { if (guard.isBlocked(req.ip, Date.now()) || db.isBlocked(req.ip)) { return reply.code(403).send({ error: 'forbidden' }); } }); const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => { const hdr = req.headers.authorization ?? ''; const token = /^Bearer\s+(.+)$/i.exec(hdr)?.[1] ?? ''; if (!token || !safeEqual(token, opts.adminToken)) { const tripped = guard.recordFail(req.ip, Date.now()); return reply.code(401).send({ error: 'unauthorized', blocked: tripped }); } guard.recordSuccess(req.ip); }; const writeLimit = { config: { rateLimit: { max: opts.writeRateLimitMax ?? 20, timeWindow: '1 minute' } }, }; app.get('/healthz', async () => ({ ok: true })); app.get('/api/whoami', { preHandler: requireAdmin }, async () => ({ admin: true })); app.get('/api/labels', async () => ({ labels: db.all() })); app.get('/api/labels/:zoneId', async (req: FastifyRequest<{ Params: ZoneParams }>, reply) => { const label = db.get(req.params.zoneId); if (!label) return reply.code(404).send({ error: 'not_found' }); return label; }); app.put( '/api/labels/:zoneId', { preHandler: requireAdmin, ...writeLimit }, async (req: FastifyRequest<{ Params: ZoneParams; Body: PutBody }>, reply) => { const body = req.body ?? {}; if (!isLabelKind(body.kind)) { return reply.code(400).send({ error: 'bad_kind', allowed: LABEL_KINDS }); } const label: ZoneLabel = { zoneId: String(req.params.zoneId), customerId: str(body.customerId), zoneName: str(body.zoneName), kind: body.kind, note: body.note == null ? null : String(body.note).slice(0, 500), updatedAt: Date.now(), updatedBy: 'admin', }; db.upsert(label); return label; }, ); app.delete( '/api/labels/:zoneId', { preHandler: requireAdmin, ...writeLimit }, async (req: FastifyRequest<{ Params: ZoneParams }>, reply) => { if (!db.delete(req.params.zoneId)) return reply.code(404).send({ error: 'not_found' }); return { deleted: true }; }, ); // ---- Zone mirror (for anonymous browsing) -------------------------------- const coord = (v: unknown): number | null => { const n = Number(v); return Number.isFinite(n) && n !== 0 ? n : null; }; app.get('/api/zones', async () => ({ zones: db.allZones(), count: db.zoneCount() })); // The app pushes the Zone list it just pulled (authed) from ParkSmarter so // anonymous users can read areas without a ParkSmarter login. app.post( '/api/zones/sync', { preHandler: requireAdmin, ...writeLimit }, async (req: FastifyRequest<{ Body: { zones?: unknown[] } }>, reply) => { const zones = Array.isArray(req.body?.zones) ? req.body!.zones : null; if (!zones) return reply.code(400).send({ error: 'zones_required' }); const rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }> = []; for (const z of zones as Array>) { if (z == null || z.ZoneId == null) continue; rows.push({ zoneId: String(z.ZoneId), zoneName: z.ZoneName != null ? String(z.ZoneName) : null, lat: coord(z.Lat), long: coord(z.Long), data: JSON.stringify(z), }); } return { synced: db.upsertZones(rows), total: db.zoneCount() }; }, ); // ---- City parking-map areas --------------------------------------------- // The colour-coded areas from the city's printed Downtown & Waterfront parking // map, georeferenced. Purely local geography: none of this touches ParkSmarter, // which is the point — tracking time on these spots must work with no IPS call. const num = (v: unknown, fallback: number): number => { const n = Number(v); return Number.isFinite(n) ? n : fallback; }; app.get('/api/areas', async () => ({ areas: db.allAreas(), overlay: db.getOverlay(), count: db.areaCount(), updatedAt: db.areasUpdatedAt(), })); // Replace-all, not upsert: the areas come from one source document, so a partial // update would leave a map matching neither the old print nor the new one. app.put( '/api/areas', { preHandler: requireAdmin, ...writeLimit }, async (req: FastifyRequest<{ Body: { areas?: unknown[] } }>, reply) => { const input = Array.isArray(req.body?.areas) ? req.body!.areas : null; if (!input) return reply.code(400).send({ error: 'areas_required' }); const areas: ParkingArea[] = []; for (const raw of input as Array>) { if (raw == null || raw.id == null) continue; if (!isAreaKind(raw.kind)) { return reply.code(400).send({ error: 'bad_kind', id: raw.id, allowed: AREA_KINDS }); } const g = raw.geometry as { type?: unknown } | null; if (!g || (g.type !== 'LineString' && g.type !== 'Polygon')) { return reply.code(400).send({ error: 'bad_geometry', id: raw.id }); } areas.push({ id: String(raw.id), kind: raw.kind, name: String(raw.name ?? raw.id), label: String(raw.label ?? raw.kind), legend: String(raw.legend ?? ''), hours: num(raw.hours, 0), color: String(raw.color ?? '#888888'), shape: g.type === 'Polygon' ? 'polygon' : 'line', geometry: g, }); } return { replaced: db.replaceAreas(areas), total: db.areaCount() }; }, ); // Whole-overlay alignment correction, set from the phone against a live GPS fix. app.put( '/api/areas/overlay', { preHandler: requireAdmin, ...writeLimit }, async (req: FastifyRequest<{ Body: Record }>, reply) => { const b = req.body ?? {}; const scale = num(b.scale, 1); if (scale <= 0.5 || scale >= 2) { // A fit that needs more than a ±2x correction is a broken fit, not a nudge. return reply.code(400).send({ error: 'scale_out_of_range' }); } return db.setOverlay({ dxMeters: num(b.dxMeters, 0), dyMeters: num(b.dyMeters, 0), scale, rotationDeg: num(b.rotationDeg, 0), }); }, ); app.addHook('onClose', async () => db.close()); return app; }