server: zone-labels API (Fastify + SQLite) — Phase A
New server/ service classifying zones as free_2h/3h/4h street vs pay_immediate lots. Public reads; writes require the admin bearer token (timing-safe compare). @fastify/rate-limit (120/min global, 20/min writes), manual IP denylist + auto-block on repeated auth failures. SQLite via better-sqlite3. Dockerfile + compose (loopback-only, mem/cpu capped) + nginx block + README. 9 tests pass. Deployed live at https://bigbrainparking.mowden.top (behind nginx + certbot). Not an npm workspace — kept out of the app/CI install to avoid the native dep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2434879804
commit
c14081d85f
14 changed files with 2306 additions and 0 deletions
114
server/src/db.ts
Normal file
114
server/src/db.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate';
|
||||
export const LABEL_KINDS: LabelKind[] = ['free_2h', 'free_3h', 'free_4h', 'pay_immediate'];
|
||||
|
||||
export function isLabelKind(v: unknown): v is LabelKind {
|
||||
return typeof v === 'string' && (LABEL_KINDS as string[]).includes(v);
|
||||
}
|
||||
|
||||
export interface ZoneLabel {
|
||||
zoneId: string;
|
||||
customerId: string | null;
|
||||
zoneName: string | null;
|
||||
kind: LabelKind;
|
||||
note: string | null;
|
||||
updatedAt: number;
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
zone_id: string;
|
||||
customer_id: string | null;
|
||||
zone_name: string | null;
|
||||
kind: string;
|
||||
note: string | null;
|
||||
updated_at: number;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
const toLabel = (r: Row): ZoneLabel => ({
|
||||
zoneId: r.zone_id,
|
||||
customerId: r.customer_id,
|
||||
zoneName: r.zone_name,
|
||||
kind: r.kind as LabelKind,
|
||||
note: r.note,
|
||||
updatedAt: r.updated_at,
|
||||
updatedBy: r.updated_by,
|
||||
});
|
||||
|
||||
/** SQLite-backed store for zone labels + a manual IP denylist. */
|
||||
export class LabelDb {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(path: string) {
|
||||
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
||||
this.db = new Database(path);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS zone_labels (
|
||||
zone_id TEXT PRIMARY KEY,
|
||||
customer_id TEXT,
|
||||
zone_name TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
note TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_by TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS blocked_ips (
|
||||
ip TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
all(): ZoneLabel[] {
|
||||
return (this.db.prepare('SELECT * FROM zone_labels ORDER BY zone_id').all() as Row[]).map(toLabel);
|
||||
}
|
||||
|
||||
get(zoneId: string): ZoneLabel | undefined {
|
||||
const r = this.db.prepare('SELECT * FROM zone_labels WHERE zone_id = ?').get(zoneId) as Row | undefined;
|
||||
return r ? toLabel(r) : undefined;
|
||||
}
|
||||
|
||||
upsert(label: ZoneLabel): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO zone_labels (zone_id, customer_id, zone_name, kind, note, updated_at, updated_by)
|
||||
VALUES (@zoneId, @customerId, @zoneName, @kind, @note, @updatedAt, @updatedBy)
|
||||
ON CONFLICT(zone_id) DO UPDATE SET
|
||||
customer_id = excluded.customer_id,
|
||||
zone_name = excluded.zone_name,
|
||||
kind = excluded.kind,
|
||||
note = excluded.note,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by`,
|
||||
)
|
||||
.run(label);
|
||||
}
|
||||
|
||||
delete(zoneId: string): boolean {
|
||||
return this.db.prepare('DELETE FROM zone_labels WHERE zone_id = ?').run(zoneId).changes > 0;
|
||||
}
|
||||
|
||||
isBlocked(ip: string): boolean {
|
||||
return !!this.db.prepare('SELECT 1 FROM blocked_ips WHERE ip = ?').get(ip);
|
||||
}
|
||||
|
||||
block(ip: string, reason: string): void {
|
||||
this.db
|
||||
.prepare('INSERT OR REPLACE INTO blocked_ips (ip, reason, created_at) VALUES (?, ?, ?)')
|
||||
.run(ip, reason, Date.now());
|
||||
}
|
||||
|
||||
seedBlocked(ips: string[]): void {
|
||||
for (const ip of ips) this.block(ip, 'seed');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue