220 lines
8.5 KiB
TypeScript
220 lines
8.5 KiB
TypeScript
import { persist } from '@/lib/db/client';
|
|
import type { GameDb } from '@/lib/db/client';
|
|
import { requireAdmin } from '@/lib/auth';
|
|
|
|
// v0.71.0 — generic whole-database CRUD for the Admin modal (admin only, guarded BEFORE any
|
|
// user input is touched). SQL safety: table names are quoted only after validation against
|
|
// sqlite_master, column names only after validation against PRAGMA table_info, and VALUES ARE
|
|
// ALWAYS BOUND AS PARAMETERS — no user input is ever concatenated into a SQL string.
|
|
|
|
const DEFAULT_LIMIT = 500;
|
|
const MAX_LIMIT = 1000;
|
|
|
|
function quoteIdent(name: string): string {
|
|
return '"' + name.replace(/"/g, '""') + '"';
|
|
}
|
|
|
|
// The app's own tables (sqlite_% internals excluded) — the only names ever accepted.
|
|
function tableNames(inst: GameDb): Set<string> {
|
|
const res = inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
|
|
return new Set(res[0].values.map((r) => String(r[0])));
|
|
}
|
|
|
|
// Column names of a validated table (name already checked against sqlite_master).
|
|
function columnsOf(inst: GameDb, name: string): string[] {
|
|
const res = inst.raw.exec(`PRAGMA table_info('${name.replace(/'/g, "''")}')`);
|
|
return res[0].values.map((r) => String(r[1]));
|
|
}
|
|
|
|
// Parameterized execute — the only way values reach SQL in this file.
|
|
function run(inst: GameDb, sql: string, values: unknown[]): void {
|
|
inst.raw.run(sql, values as never[]);
|
|
}
|
|
|
|
async function readBody(req: Request): Promise<Record<string, unknown> | Response> {
|
|
let body: unknown;
|
|
try {
|
|
const text = await req.text();
|
|
body = text ? JSON.parse(text) : undefined;
|
|
} catch {
|
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
|
}
|
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
|
}
|
|
const input = body as Record<string, unknown>;
|
|
for (const key of Object.keys(input)) {
|
|
if (!ALLOWED_BODY_FIELDS.has(key)) {
|
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
|
}
|
|
}
|
|
return input;
|
|
}
|
|
|
|
// Per-method top-level body fields (strict, like the /api/test/* hooks).
|
|
const ALLOWED_BODY_FIELDS = new Set(['rowid', 'values']);
|
|
|
|
function valuesObject(input: Record<string, unknown>): Record<string, unknown> | Response {
|
|
const values = input.values;
|
|
if (typeof values !== 'object' || values === null || Array.isArray(values)) {
|
|
return Response.json({ error: 'values must be a JSON object of column → value' }, { status: 400 });
|
|
}
|
|
return values as Record<string, unknown>;
|
|
}
|
|
|
|
// Constraint violations (NOT NULL / UNIQUE) are the expected failure mode of a dev-tool edit;
|
|
// anything else is a server error.
|
|
function sqlError(err: unknown): Response {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (/NOT NULL|UNIQUE|constraint failed/i.test(msg)) {
|
|
return Response.json({ error: `database constraint: ${msg}` }, { status: 409 });
|
|
}
|
|
console.error('admin rows route failed', err);
|
|
return Response.json({ error: 'database error' }, { status: 500 });
|
|
}
|
|
|
|
// GET /api/admin/tables/{name}/rows?limit=500 — column headers + up to `limit` rows (default
|
|
// 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (account, sim_state) are
|
|
// editable like any other. Unknown table → 404; bad limit → 400.
|
|
export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
|
const guard = await requireAdmin(req);
|
|
if (!guard.ok) return guard.response;
|
|
|
|
const { name } = await ctx.params;
|
|
if (!tableNames(guard.inst).has(name)) {
|
|
return Response.json({ error: 'unknown table' }, { status: 404 });
|
|
}
|
|
let limit = DEFAULT_LIMIT;
|
|
const limitParam = new URL(req.url).searchParams.get('limit');
|
|
if (limitParam !== null) {
|
|
limit = Number(limitParam);
|
|
if (!Number.isInteger(limit) || limit < 1) {
|
|
return Response.json({ error: 'limit must be a positive integer' }, { status: 400 });
|
|
}
|
|
limit = Math.min(limit, MAX_LIMIT);
|
|
}
|
|
|
|
const cols = columnsOf(guard.inst, name);
|
|
const stmt = guard.inst.raw.prepare(`SELECT rowid AS __rowid, * FROM ${quoteIdent(name)} LIMIT ?`);
|
|
try {
|
|
stmt.bind([limit]);
|
|
const rows: Array<Record<string, unknown>> = [];
|
|
while (stmt.step()) rows.push(stmt.getAsObject() as Record<string, unknown>);
|
|
return Response.json({ columns: ['__rowid', ...cols], rows });
|
|
} catch (err) {
|
|
return sqlError(err);
|
|
} finally {
|
|
stmt.free();
|
|
}
|
|
}
|
|
|
|
// PUT /api/admin/tables/{name}/rows — body { rowid, values: { col: value } }. Updates only the
|
|
// given columns of that row. Unknown table → 404; unknown column or non-integer rowid or empty
|
|
// values → 400.
|
|
export async function PUT(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
|
const guard = await requireAdmin(req);
|
|
if (!guard.ok) return guard.response;
|
|
|
|
const input = await readBody(req);
|
|
if (input instanceof Response) return input;
|
|
const rowid = input.rowid;
|
|
if (!Number.isInteger(rowid)) {
|
|
return Response.json({ error: 'rowid is required and must be an integer' }, { status: 400 });
|
|
}
|
|
const values = await valuesObject(input);
|
|
if (values instanceof Response) return values;
|
|
|
|
const { name } = await ctx.params;
|
|
if (!tableNames(guard.inst).has(name)) {
|
|
return Response.json({ error: 'unknown table' }, { status: 404 });
|
|
}
|
|
const cols = columnsOf(guard.inst, name);
|
|
for (const col of Object.keys(values)) {
|
|
if (!cols.includes(col)) {
|
|
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
|
|
}
|
|
}
|
|
if (Object.keys(values).length === 0) {
|
|
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
|
|
}
|
|
|
|
const setClause = Object.keys(values).map((c) => `${quoteIdent(c)} = ?`).join(', ');
|
|
try {
|
|
run(guard.inst, `UPDATE ${quoteIdent(name)} SET ${setClause} WHERE rowid = ?`, [
|
|
...Object.values(values),
|
|
rowid,
|
|
]);
|
|
await persist(guard.inst);
|
|
return Response.json({ ok: true });
|
|
} catch (err) {
|
|
return sqlError(err);
|
|
}
|
|
}
|
|
|
|
// POST /api/admin/tables/{name}/rows — body { values: { col: value } }. Inserts a new row with
|
|
// exactly the given columns (others take their defaults or NULL). Returns the new rowid.
|
|
export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
|
const guard = await requireAdmin(req);
|
|
if (!guard.ok) return guard.response;
|
|
|
|
const input = await readBody(req);
|
|
if (input instanceof Response) return input;
|
|
const values = await valuesObject(input);
|
|
if (values instanceof Response) return values;
|
|
if (Object.keys(values).length === 0) {
|
|
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
|
|
}
|
|
|
|
const { name } = await ctx.params;
|
|
if (!tableNames(guard.inst).has(name)) {
|
|
return Response.json({ error: 'unknown table' }, { status: 404 });
|
|
}
|
|
const cols = columnsOf(guard.inst, name);
|
|
for (const col of Object.keys(values)) {
|
|
if (!cols.includes(col)) {
|
|
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
const keys = Object.keys(values);
|
|
try {
|
|
run(
|
|
guard.inst,
|
|
`INSERT INTO ${quoteIdent(name)} (${keys.map(quoteIdent).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
|
Object.values(values),
|
|
);
|
|
// Read the new rowid BEFORE persist(): sql.js's export() (inside persist) resets
|
|
// last_insert_rowid to 0 on this connection.
|
|
const res = guard.inst.raw.exec('SELECT last_insert_rowid()');
|
|
await persist(guard.inst);
|
|
return Response.json({ ok: true, rowid: Number(res[0].values[0][0]) });
|
|
} catch (err) {
|
|
return sqlError(err);
|
|
}
|
|
}
|
|
|
|
// DELETE /api/admin/tables/{name}/rows?rowid=N — deletes that one row. Unknown table → 404;
|
|
// missing/non-integer rowid → 400. Deleting a non-existent rowid is still 200 (idempotent).
|
|
export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
|
const guard = await requireAdmin(req);
|
|
if (!guard.ok) return guard.response;
|
|
|
|
const { name } = await ctx.params;
|
|
if (!tableNames(guard.inst).has(name)) {
|
|
return Response.json({ error: 'unknown table' }, { status: 404 });
|
|
}
|
|
const rowidParam = new URL(req.url).searchParams.get('rowid');
|
|
const rowid = Number(rowidParam);
|
|
if (rowidParam === null || !Number.isInteger(rowid)) {
|
|
return Response.json({ error: 'rowid query parameter is required and must be an integer' }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
run(guard.inst, `DELETE FROM ${quoteIdent(name)} WHERE rowid = ?`, [rowid]);
|
|
await persist(guard.inst);
|
|
return Response.json({ ok: true });
|
|
} catch (err) {
|
|
return sqlError(err);
|
|
}
|
|
}
|