Files
GhostGrid/server-db.ts
Brückner 08a4df5503 feat(topology): add personal/global scope to lab templates
Labs can now be marked as Personal or Global when creating or editing.
Personal topologies are visible only to the owner and admins; others
cannot see, book, or edit them. Global topologies are visible to all
but editable only by the creator, admins, or legacy (migrated) labs.

- DB: idempotent ALTER TABLE adds scope + ownerId columns to labs
- API: POST sets ownerId from JWT; PUT/DELETE enforce ownership (403 for
  unauthorized edits; legacy ownerId='' remains freely editable)
- Types: LabTemplate extended with scope and ownerId fields
- LabTemplates UI: sectioned list (My / Global / Others' Personal),
  Personal/Global toggle in form, Lock/Globe badges on cards,
  edit+delete buttons hidden for non-owners
- BookingCalendar: personal labs filtered from selects/quick booking,
  optgroup grouping for Global vs Personal in topology dropdown
- Light mode: add missing bg-slate-950/50 and border-slate-800/50
  overrides so the Global badge renders correctly
2026-06-10 15:51:53 +02:00

208 lines
7.1 KiB
TypeScript

import Database from 'better-sqlite3';
import path from 'path';
export const DB_FILE = path.join(process.cwd(), 'ghostgrid.db');
/** App-generated primary key: `${prefix}-${epochMs}-${rand}` (e.g. `log-…`, `dev-…`). */
export const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`);
const db = new Database(DB_FILE);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'User',
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
ip TEXT NOT NULL,
location TEXT NOT NULL,
notes TEXT,
type TEXT NOT NULL,
status TEXT NOT NULL,
emergencySheet TEXT NOT NULL,
lastCheckedAt TEXT,
checkMkUrl TEXT NOT NULL DEFAULT '',
cmkHostname TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS labs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
contactPerson TEXT NOT NULL,
location TEXT NOT NULL,
deviceIds TEXT NOT NULL,
topology TEXT NOT NULL,
semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '',
semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS bookings (
id TEXT PRIMARY KEY,
labId TEXT NOT NULL,
userId TEXT NOT NULL,
startDateTime TEXT NOT NULL,
endDateTime TEXT NOT NULL,
notes TEXT,
status TEXT NOT NULL,
notified INTEGER NOT NULL DEFAULT 0,
emailSent INTEGER NOT NULL DEFAULT 0,
ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0,
ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0,
ansibleSetupJobId TEXT NOT NULL DEFAULT '',
ansibleTeardownJobId TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
deviceId TEXT,
userId TEXT
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'emerald',
createdBy TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS caddy (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
upstream TEXT NOT NULL,
tls INTEGER NOT NULL DEFAULT 1,
compress INTEGER NOT NULL DEFAULT 1,
redirect TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
`);
// Idempotent migrations — SQLite throws on duplicate column; the catch makes them safe to re-run.
try { db.prepare("ALTER TABLE labs ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'").run(); } catch {}
try { db.prepare("ALTER TABLE labs ADD COLUMN ownerId TEXT NOT NULL DEFAULT ''").run(); } catch {}
// Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
const DEFAULT_SETTINGS: Record<string, string> = {
azure_enabled: 'false',
azure_client_id: '',
azure_tenant_id: '',
azure_client_secret: '',
azure_redirect_uri: '',
azure_allowed_group: '',
checkmk_enabled: 'false',
checkmk_api_url: '',
checkmk_api_user: 'automation',
checkmk_api_secret: '',
checkmk_sync_interval_ms: '60000',
semaphore_enabled: 'false',
semaphore_api_url: '',
semaphore_api_token: '',
semaphore_project_id: '',
caddy_enabled: 'false',
caddy_admin_url: 'http://127.0.0.1:2019',
};
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) seedSetting.run(key, value);
export function getSetting(key: string): string {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value ?? '';
}
export function setSetting(key: string, value: string): void {
db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))").run(key, value);
}
export function getAllSettings(): Record<string, string> {
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
return Object.fromEntries(rows.map(r => [r.key, r.value]));
}
const insertLog = db.prepare(
'INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)',
);
/**
* Append a logbook entry. `deviceId`/`userId` default to NULL; `timestamp`
* defaults to now (pass one to share a single timestamp across a batch).
* Returns the generated log id.
*/
export function addLog(
type: string,
message: string,
opts: { deviceId?: string | null; userId?: string | null; timestamp?: string } = {},
): string {
const id = uid('log');
insertLog.run(id, opts.timestamp ?? new Date().toISOString(), type, message, opts.deviceId ?? null, opts.userId ?? null);
return id;
}
/** A reverse-proxy route as stored (booleans are SQLite 0/1 integers). */
export interface CaddyRoute {
id: number;
hostname: string;
upstream: string;
tls: number;
compress: number;
redirect: string;
created_at: string;
}
/** Fields a caller supplies to create or update a route (JS booleans). */
export interface CaddyRouteInput {
hostname: string;
upstream: string;
tls: boolean;
compress: boolean;
redirect?: string;
}
export function getCaddyRoutes(): CaddyRoute[] {
return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[];
}
export function getCaddyRouteById(id: number): CaddyRoute | undefined {
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
}
export function addCaddyRoute(route: CaddyRouteInput): CaddyRoute {
const { lastInsertRowid } = db.prepare(
'INSERT INTO caddy (hostname, upstream, tls, compress, redirect) VALUES (?, ?, ?, ?, ?)',
).run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '');
return getCaddyRouteById(Number(lastInsertRowid))!;
}
export function updateCaddyRoute(id: number, route: CaddyRouteInput): CaddyRoute {
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect = ? WHERE id = ?')
.run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '', id);
return getCaddyRouteById(id)!;
}
export function deleteCaddyRoute(id: number): void {
db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
}
export default db;