BigBrainParking/server/src/app.ts
Erik 4217e88338
All checks were successful
build-apk / build (push) Successful in 9m54s
v0.6.3: hide the paid city lots from users without a ParkSmarter account
The green lots are the map's only paid category ("City lots - Paid hourly or
permit"); paying for them goes through ParkSmarter, so they are no use to
someone browsing without an account. Every other category is free street
parking with a posted time limit and needs nothing.

Gated by category rather than by an id list. Two lots were named (off Oak St
and off N 3rd Ave) and then the beach ones, which together is every green lot
on the map — and an id list would silently break the next time the map is
regenerated from a new PDF, since ids are positional.

- Paid lots are filtered out of the overlay when signed out, so they are
  neither drawn nor tappable.
- "Park here" still detects them, so standing in one explains that it needs an
  account instead of reporting no parking nearby.
- The area screen guards too, in case one is reached with a stale nav param.
- Server carries an optional per-area requiresAccount override for a lot that
  turns out to take payment another way. Null means "use the category
  default", so an unset value can't be confused with an explicit false.

The new column needs a real migration: CREATE TABLE IF NOT EXISTS does not add
a column to a table that already exists, so an already-deployed server would
have kept the old schema. Covered by a test that builds the pre-migration
table and then opens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 06:02:23 +00:00

231 lines
8.1 KiB
TypeScript

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<Record<string, unknown>>) {
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<Record<string, unknown>>) {
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,
// Absent means "let the client decide by category"; only an explicit
// boolean overrides a specific lot.
requiresAccount: typeof raw.requiresAccount === 'boolean' ? raw.requiresAccount : null,
});
}
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<string, unknown> }>, 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;
}