All checks were successful
build-apk / build (push) Successful in 10m38s
Adds the City of Sandpoint's printed "Downtown & Waterfront Public Parking" map as a georeferenced overlay, and lets you track your time on any of its areas without ever touching the ParkSmarter/IPS API. Georeferencing (tools/citymap/) - The PDF carries no geo metadata, so the page->WebMercator affine is recovered by fitting the drawing to OSM street centrelines. - pdftocairo writes stroked street segments with per-path matrix() transforms in local coords while filled lots are absolute; both are handled. The five legend swatches share the real geometry's colours and are identified by stroke-width and position, then dropped. - 49 areas, fitted to RMS 4.1 m (X) / 3.5 m (Y). On-street segments land a mean 4.0 m from the nearest OSM road. sp-039/040 sit further out because they are angled bays along the old rail corridor, on no named road at all. - Sandpoint's grid jogs 38 m between N 2nd Ave and S 2nd Ave; the page shows the same jog at the fitted scale, which independently confirms the fit. App - Map tab: "City map" layer in the legend's colours, tappable. - "Park here" pins the car from GPS and auto-detects the containing area (40 m snap). With no fix it asks you to tap the spot instead, so the pin never depends on GPS working. - The pin lives in its own storage key, not inside the session: pinning the car without starting a timer must survive backing out of the screen. - Durations cap at the posted limit — a 2-hour space is not offered a 4-hour timer. Lots and no-limit spots get the long options. - Reuses the existing foreground-service countdown. The second notification button reads "+1 hr" for a city area rather than "Extend": there is nothing to buy, so it edits the local timer and says so. - Account -> Align city map: nudge/scale/rotate the whole overlay against a live GPS fix. Save-on-phone needs no admin token, since the person who can see the misalignment is the one standing on the street. Server - parking_areas + map_overlay tables, public read, admin replace-all. The areas come from one source document, so replacement is wholesale rather than an upsert. Dropped geometryCenter from the geo module: on the real data it returns a point in the water for the crescent City Beach lot and mid-block for L-shaped runs. Nothing used it. Tests: 8 geometry tests in app/, 5 area/overlay tests in server/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
7.9 KiB
TypeScript
228 lines
7.9 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,
|
|
});
|
|
}
|
|
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;
|
|
}
|