mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
feat(db): full async Repository conversion for PostgreSQL support
Mechanical conversion of the entire data access layer from synchronous
node:sqlite API to async DbAdapter interface. Enables PostgreSQL
(PgAdapter) as a drop-in backend alongside SQLite (SqliteAdapter).
Repository (2208 lines):
- Constructor accepts DbAdapter instead of DatabaseSync
- Internal _run/_get/_all/_exec helpers wrap adapter calls
- All 155 methods converted to async, return Promise<T>
- transact() uses adapter.transaction() (supports PG savepoints)
14 caller files updated (327 call sites):
- routes-admin.ts: 202 repo calls + 6 async helper functions
- service-api-http: 40 repo calls + async getClusterKey
- routes-firmware.ts, routes-os-updates.ts, routes-auth.ts,
routes-setup.ts, middleware.ts: all handlers made async
- shared/auth.ts: resolveSession + revokeSession now async
- shared/bundle.ts: generateBundle now async, .map→for..of loops
- shared/pairing.ts: all 3 functions async
- shared/audit.ts: audit() now async
- shared/camera-health.ts: checkAll repo calls awaited
- service-coordinator-ws: session + kiosk lookups awaited
- service-store/index.ts: creates SqliteAdapter.fromExisting()
SqliteAdapter gains static fromExisting(db) factory for wrapping an
already-opened DatabaseSync (migrations run on raw db, then adapter
wraps for Repository queries).
tsc --noEmit: zero errors.
This commit is contained in:
parent
46fcbe5197
commit
ed2050cfd8
17 changed files with 1329 additions and 1249 deletions
|
|
@ -170,7 +170,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
|
||||
// Auth-check endpoint for Angie auth_request subrequest.
|
||||
// Returns 200 if session cookie is valid + admin role, 401 otherwise.
|
||||
app.get("/api/admin/_check", (event) => {
|
||||
app.get("/api/admin/_check", async (event) => {
|
||||
const authz = event.req.headers.get("authorization");
|
||||
if (authz?.startsWith("Bearer ")) {
|
||||
return deps.auth.verifyApiKey(authz.slice(7), event.req.headers.get("x-real-ip")).then((key) => {
|
||||
|
|
@ -185,7 +185,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
const cookie = event.req.headers.get("cookie") ?? "";
|
||||
const match = cookie.match(new RegExp(`${deps.cookieName}=([^;]+)`));
|
||||
if (!match) return new Response(null, { status: 401 });
|
||||
const resolved = deps.auth.resolveSession(match[1]!);
|
||||
const resolved = await deps.auth.resolveSession(match[1]!);
|
||||
if (!resolved || resolved.session.totp_pending) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
|
@ -199,9 +199,9 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
});
|
||||
|
||||
app.get("/healthz", () => ({ status: "ok" }));
|
||||
app.get("/readyz", () => {
|
||||
app.get("/readyz", async () => {
|
||||
try {
|
||||
deps.repo.isSetupComplete();
|
||||
await deps.repo.isSetupComplete();
|
||||
return { status: "ready" };
|
||||
} catch {
|
||||
return { status: "not_ready" };
|
||||
|
|
@ -290,7 +290,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
auth: AuthApi,
|
||||
): Promise<string> {
|
||||
const KEY = "nodered_api_key";
|
||||
const stored = repo.getSetupExtra(KEY);
|
||||
const stored = await repo.getSetupExtra(KEY);
|
||||
if (typeof stored === "string" && stored.length > 0) {
|
||||
return secrets.decryptString(stored, "nodered_api_key");
|
||||
}
|
||||
|
|
@ -299,7 +299,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
scopes: ["admin"],
|
||||
expiresAt: null,
|
||||
});
|
||||
repo.setSetupExtra(KEY, secrets.encryptString(plaintext, "nodered_api_key"));
|
||||
await repo.setSetupExtra(KEY, secrets.encryptString(plaintext, "nodered_api_key"));
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!deps.repo.isSetupComplete()) {
|
||||
if (!(await deps.repo.isSetupComplete())) {
|
||||
if (!path.startsWith("/auth/")) {
|
||||
return new Response(null, { status: 302, headers: { location: "/setup" } });
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
|
|||
if (!cookie) {
|
||||
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||
}
|
||||
const resolved = deps.auth.resolveSession(cookie);
|
||||
const resolved = await deps.auth.resolveSession(cookie);
|
||||
if (!resolved) {
|
||||
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -27,7 +27,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
|
||||
?? "anon";
|
||||
if (!loginGuard.take(`login:${ip}`)) {
|
||||
audit(deps.repo, event as any, "user.login", {
|
||||
await audit(deps.repo, event as any, "user.login", {
|
||||
result: "failed",
|
||||
metadata: { reason: "rate_limited", ip },
|
||||
});
|
||||
|
|
@ -45,7 +45,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
return htmlPage(LoginPage({ error: "Username and password required.", username }));
|
||||
}
|
||||
|
||||
const user = deps.repo.getUserByUsername(username);
|
||||
const user = await deps.repo.getUserByUsername(username);
|
||||
if (!user || !user.is_active) {
|
||||
return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
|
||||
}
|
||||
|
|
@ -64,8 +64,8 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (count >= deps.auth.config.loginLockoutThreshold) {
|
||||
patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString();
|
||||
}
|
||||
deps.repo.updateUser(user.id, patch);
|
||||
audit(deps.repo, event as any, "user.login", {
|
||||
await deps.repo.updateUser(user.id, patch);
|
||||
await audit(deps.repo, event as any, "user.login", {
|
||||
result: "failed",
|
||||
actor_type: "system",
|
||||
actor_label: username,
|
||||
|
|
@ -74,13 +74,13 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
|
||||
}
|
||||
|
||||
deps.repo.updateUser(user.id, {
|
||||
await deps.repo.updateUser(user.id, {
|
||||
failed_login_count: 0,
|
||||
locked_until: null,
|
||||
last_login_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
audit(deps.repo, event as any, "user.login", {
|
||||
await audit(deps.repo, event as any, "user.login", {
|
||||
actor_type: "user",
|
||||
actor_id: user.id,
|
||||
actor_label: user.username,
|
||||
|
|
@ -106,12 +106,12 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
|
||||
// ---- TOTP -----------------------------------------------------------------
|
||||
|
||||
app.get("/auth/totp", (event) => {
|
||||
app.get("/auth/totp", async (event) => {
|
||||
const cookie = getCookie(event, deps.cookieName);
|
||||
if (!cookie) {
|
||||
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||
}
|
||||
const resolved = deps.auth.resolveSession(cookie);
|
||||
const resolved = await deps.auth.resolveSession(cookie);
|
||||
if (!resolved || !resolved.session.totp_pending) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
|
|
@ -123,7 +123,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (!cookie) {
|
||||
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||
}
|
||||
const resolved = deps.auth.resolveSession(cookie);
|
||||
const resolved = await deps.auth.resolveSession(cookie);
|
||||
if (!resolved || !resolved.session.totp_pending) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
|
|
@ -146,18 +146,18 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
return htmlPage(TotpPage({ error: "Invalid code. Try again." }));
|
||||
}
|
||||
|
||||
deps.repo.setSessionTotpPending(session.id, false);
|
||||
await deps.repo.setSessionTotpPending(session.id, false);
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
});
|
||||
|
||||
// ---- Recovery code --------------------------------------------------------
|
||||
|
||||
app.get("/auth/recovery", (event) => {
|
||||
app.get("/auth/recovery", async (event) => {
|
||||
const cookie = getCookie(event, deps.cookieName);
|
||||
if (!cookie) {
|
||||
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||
}
|
||||
const resolved = deps.auth.resolveSession(cookie);
|
||||
const resolved = await deps.auth.resolveSession(cookie);
|
||||
if (!resolved || !resolved.session.totp_pending) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (!cookie) {
|
||||
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||
}
|
||||
const resolved = deps.auth.resolveSession(cookie);
|
||||
const resolved = await deps.auth.resolveSession(cookie);
|
||||
if (!resolved || !resolved.session.totp_pending) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
|
|
@ -189,22 +189,22 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
return htmlPage(RecoveryPage({ error: "Invalid recovery code." }));
|
||||
}
|
||||
|
||||
deps.repo.updateUser(user.id, {
|
||||
await deps.repo.updateUser(user.id, {
|
||||
recovery_codes_hashed: result.remaining,
|
||||
});
|
||||
|
||||
deps.repo.setSessionTotpPending(session.id, false);
|
||||
await deps.repo.setSessionTotpPending(session.id, false);
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
});
|
||||
|
||||
// ---- Logout ---------------------------------------------------------------
|
||||
|
||||
app.post("/auth/logout", (event) => {
|
||||
app.post("/auth/logout", async (event) => {
|
||||
const cookie = getCookie(event, deps.cookieName);
|
||||
if (cookie) {
|
||||
const resolved = deps.auth.resolveSession(cookie);
|
||||
const resolved = await deps.auth.resolveSession(cookie);
|
||||
if (resolved) {
|
||||
deps.auth.revokeSession(resolved.session.id);
|
||||
await deps.auth.revokeSession(resolved.session.id);
|
||||
}
|
||||
}
|
||||
return redirectClearCookie("/auth/login", deps.cookieName);
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ const ALLOWED_ARCHES = new Set([
|
|||
|
||||
export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
||||
// ---- List page -----------------------------------------------------------
|
||||
app.get("/admin/firmware", (event) => {
|
||||
app.get("/admin/firmware", async (event) => {
|
||||
const user = event.context.user!;
|
||||
const releases = deps.repo.listFirmwareReleases();
|
||||
const releases = await deps.repo.listFirmwareReleases();
|
||||
return htmlPage(FirmwarePage({
|
||||
user: user.username,
|
||||
releases,
|
||||
|
|
@ -70,7 +70,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
const { sha256, signature } = deps.firmware.signBlob(buf);
|
||||
const artifactPath = await deps.firmware.storeBlob(buf, sha256);
|
||||
|
||||
const release = deps.repo.createFirmwareRelease({
|
||||
const release = await deps.repo.createFirmwareRelease({
|
||||
id: randomUUID(),
|
||||
version,
|
||||
channel: channelRaw as FirmwareChannel,
|
||||
|
|
@ -82,7 +82,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
release_notes: releaseNotes,
|
||||
uploaded_by: user.id,
|
||||
});
|
||||
audit(deps.repo, event as any, "firmware.upload", {
|
||||
await audit(deps.repo, event as any, "firmware.upload", {
|
||||
resource_type: "firmware_release",
|
||||
resource_id: release.id,
|
||||
metadata: { version, channel: channelRaw, arch, sha256, size: buf.length },
|
||||
|
|
@ -123,7 +123,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
const { sha256, signature } = deps.firmware.signBlob(buf);
|
||||
const artifactPath = await deps.firmware.storeBlob(buf, sha256);
|
||||
const id = randomUUID();
|
||||
const release = deps.repo.createFirmwareRelease({
|
||||
const release = await deps.repo.createFirmwareRelease({
|
||||
id,
|
||||
version: body.version,
|
||||
channel: body.channel,
|
||||
|
|
@ -140,10 +140,10 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
});
|
||||
|
||||
// ---- Yank ---------------------------------------------------------------
|
||||
app.post("/admin/firmware/:id/yank", (event) => {
|
||||
app.post("/admin/firmware/:id/yank", async (event) => {
|
||||
const id = String(getRouterParam(event, "id"));
|
||||
deps.repo.yankFirmwareRelease(id);
|
||||
audit(deps.repo, event as any, "firmware.yank", {
|
||||
await deps.repo.yankFirmwareRelease(id);
|
||||
await audit(deps.repo, event as any, "firmware.yank", {
|
||||
resource_type: "firmware_release",
|
||||
resource_id: id,
|
||||
});
|
||||
|
|
@ -160,15 +160,15 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (!ALLOWED_CHANNELS.has(channelRaw)) {
|
||||
throw createError({ statusCode: 400, statusMessage: "invalid channel" });
|
||||
}
|
||||
deps.repo.setKioskFirmwarePref(id, {
|
||||
await deps.repo.setKioskFirmwarePref(id, {
|
||||
channel: channelRaw,
|
||||
target_version: targetRaw ? targetRaw : null,
|
||||
});
|
||||
const k = deps.repo.getKioskById(id);
|
||||
const k = await deps.repo.getKioskById(id);
|
||||
if (!k) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||
}
|
||||
const releases = deps.repo.listFirmwareReleases();
|
||||
const releases = await deps.repo.listFirmwareReleases();
|
||||
return htmlFragment(KioskFirmwarePanel({ kiosk: k, releases }));
|
||||
});
|
||||
|
||||
|
|
@ -183,11 +183,11 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
|
||||
// ---- Rollouts -----------------------------------------------------------
|
||||
|
||||
app.get("/admin/firmware/rollouts", (event) => {
|
||||
app.get("/admin/firmware/rollouts", async (event) => {
|
||||
const user = event.context.user!;
|
||||
const rollouts = deps.repo.listFirmwareRollouts();
|
||||
const releases = deps.repo.listFirmwareReleases();
|
||||
const kiosks = deps.repo.listKiosks();
|
||||
const rollouts = await deps.repo.listFirmwareRollouts();
|
||||
const releases = await deps.repo.listFirmwareReleases();
|
||||
const kiosks = await deps.repo.listKiosks();
|
||||
return htmlPage(FirmwareRolloutsPage({
|
||||
user: user.username,
|
||||
rollouts,
|
||||
|
|
@ -200,7 +200,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
const body = await readBody<Record<string, string | string[]>>(event);
|
||||
const releaseId = String(body?.["release_id"] ?? "");
|
||||
if (!releaseId) throw createError({ statusCode: 400, statusMessage: "release_id required" });
|
||||
const release = deps.repo.getFirmwareRelease(releaseId);
|
||||
const release = await deps.repo.getFirmwareRelease(releaseId);
|
||||
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
|
||||
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
|
||||
const targetsRaw = body?.["target_kiosk_ids"];
|
||||
|
|
@ -210,15 +210,15 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))
|
||||
: [];
|
||||
const user = event.context.user!;
|
||||
const rollout = deps.repo.createFirmwareRollout({
|
||||
const rollout = await deps.repo.createFirmwareRollout({
|
||||
id: randomUUID(),
|
||||
release_id: releaseId,
|
||||
target_kiosk_ids: targets,
|
||||
percentage,
|
||||
created_by: user.id ?? null,
|
||||
});
|
||||
deps.repo.updateFirmwareRolloutState(rollout.id, "active");
|
||||
audit(deps.repo, event as any, "firmware.rollout.create", {
|
||||
await deps.repo.updateFirmwareRolloutState(rollout.id, "active");
|
||||
await audit(deps.repo, event as any, "firmware.rollout.create", {
|
||||
resource_type: "firmware_rollout",
|
||||
resource_id: rollout.id,
|
||||
metadata: { release_id: releaseId, percentage, target_count: targets.length },
|
||||
|
|
@ -226,7 +226,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
// Bump every targeted kiosk to check now (best-effort over WS).
|
||||
const coord = getCoordinator();
|
||||
if (targets.length === 0) {
|
||||
const allKiosks = deps.repo.listKiosks();
|
||||
const allKiosks = await deps.repo.listKiosks();
|
||||
for (const k of allKiosks) coord.sendToKiosk(k.id, { type: "firmware_check" });
|
||||
} else {
|
||||
for (const id of targets) coord.sendToKiosk(id, { type: "firmware_check" });
|
||||
|
|
@ -241,7 +241,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (state !== "paused" && state !== "active" && state !== "complete") {
|
||||
throw createError({ statusCode: 400, statusMessage: "invalid state" });
|
||||
}
|
||||
deps.repo.updateFirmwareRolloutState(id, state);
|
||||
await deps.repo.updateFirmwareRolloutState(id, state);
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/firmware/rollouts" } });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,17 +25,17 @@ function clamp(n: number, lo: number, hi: number): number {
|
|||
|
||||
export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
||||
// ---- List page -----------------------------------------------------------
|
||||
app.get("/admin/os-updates", (event) => {
|
||||
app.get("/admin/os-updates", async (event) => {
|
||||
const user = event.context.user!;
|
||||
const releases = deps.repo.listOsUpdateReleases();
|
||||
const releases = await deps.repo.listOsUpdateReleases();
|
||||
return htmlPage(OsUpdatePage({ user: user.username, releases }));
|
||||
});
|
||||
|
||||
// ---- Yank ---------------------------------------------------------------
|
||||
app.post("/admin/os-updates/:id/yank", (event) => {
|
||||
app.post("/admin/os-updates/:id/yank", async (event) => {
|
||||
const id = String(getRouterParam(event, "id"));
|
||||
deps.repo.yankOsUpdateRelease(id);
|
||||
audit(deps.repo, event as any, "os_update.yank", {
|
||||
await deps.repo.yankOsUpdateRelease(id);
|
||||
await audit(deps.repo, event as any, "os_update.yank", {
|
||||
resource_type: "os_update_release",
|
||||
resource_id: id,
|
||||
});
|
||||
|
|
@ -51,15 +51,15 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (!ALLOWED_CHANNELS.has(channelRaw)) {
|
||||
throw createError({ statusCode: 400, statusMessage: "invalid channel" });
|
||||
}
|
||||
deps.repo.setKioskOsUpdatePref(id, {
|
||||
await deps.repo.setKioskOsUpdatePref(id, {
|
||||
channel: channelRaw,
|
||||
target_version: targetRaw ? targetRaw : null,
|
||||
});
|
||||
const k = deps.repo.getKioskById(id);
|
||||
const k = await deps.repo.getKioskById(id);
|
||||
if (!k) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||
}
|
||||
const releases = deps.repo.listOsUpdateReleases();
|
||||
const releases = await deps.repo.listOsUpdateReleases();
|
||||
return htmlFragment(KioskOsUpdatePanel({ kiosk: k, releases }));
|
||||
});
|
||||
|
||||
|
|
@ -72,11 +72,11 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
|||
});
|
||||
|
||||
// ---- Rollouts -----------------------------------------------------------
|
||||
app.get("/admin/os-updates/rollouts", (event) => {
|
||||
app.get("/admin/os-updates/rollouts", async (event) => {
|
||||
const user = event.context.user!;
|
||||
const rollouts = deps.repo.listOsUpdateRollouts();
|
||||
const releases = deps.repo.listOsUpdateReleases();
|
||||
const kiosks = deps.repo.listKiosks();
|
||||
const rollouts = await deps.repo.listOsUpdateRollouts();
|
||||
const releases = await deps.repo.listOsUpdateReleases();
|
||||
const kiosks = await deps.repo.listKiosks();
|
||||
return htmlPage(OsUpdateRolloutsPage({
|
||||
user: user.username,
|
||||
rollouts,
|
||||
|
|
@ -89,7 +89,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
|||
const body = await readBody<Record<string, string | string[]>>(event);
|
||||
const releaseId = String(body?.["release_id"] ?? "");
|
||||
if (!releaseId) throw createError({ statusCode: 400, statusMessage: "release_id required" });
|
||||
const release = deps.repo.getOsUpdateRelease(releaseId);
|
||||
const release = await deps.repo.getOsUpdateRelease(releaseId);
|
||||
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
|
||||
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
|
||||
const targetsRaw = body?.["target_kiosk_ids"];
|
||||
|
|
@ -99,15 +99,15 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
|||
? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))
|
||||
: [];
|
||||
const user = event.context.user!;
|
||||
const rollout = deps.repo.createOsUpdateRollout({
|
||||
const rollout = await deps.repo.createOsUpdateRollout({
|
||||
id: randomUUID(),
|
||||
release_id: releaseId,
|
||||
target_kiosk_ids: targets,
|
||||
percentage,
|
||||
created_by: user.id ?? null,
|
||||
});
|
||||
deps.repo.updateOsUpdateRolloutState(rollout.id, "active");
|
||||
audit(deps.repo, event as any, "os_update.rollout.create", {
|
||||
await deps.repo.updateOsUpdateRolloutState(rollout.id, "active");
|
||||
await audit(deps.repo, event as any, "os_update.rollout.create", {
|
||||
resource_type: "os_update_rollout",
|
||||
resource_id: rollout.id,
|
||||
metadata: { release_id: releaseId, percentage, target_count: targets.length },
|
||||
|
|
@ -122,7 +122,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (state !== "paused" && state !== "active" && state !== "complete") {
|
||||
throw createError({ statusCode: 400, statusMessage: "invalid state" });
|
||||
}
|
||||
deps.repo.updateOsUpdateRolloutState(id, state);
|
||||
await deps.repo.updateOsUpdateRolloutState(id, state);
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/os-updates/rollouts" } });
|
||||
});
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
|||
|
||||
let release;
|
||||
try {
|
||||
release = deps.repo.createOsUpdateRelease({
|
||||
release = await deps.repo.createOsUpdateRelease({
|
||||
id: randomUUID(),
|
||||
version,
|
||||
channel,
|
||||
|
|
@ -181,7 +181,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
|||
throw createError({ statusCode: 409, statusMessage: (err as Error).message });
|
||||
}
|
||||
|
||||
audit(deps.repo, event as any, "os_update.import", {
|
||||
await audit(deps.repo, event as any, "os_update.import", {
|
||||
resource_type: "os_update_release",
|
||||
resource_id: release.id,
|
||||
metadata: {
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ import type { AdminDeps } from "./index.js";
|
|||
import { SetupPage } from "../../web-templates/auth-pages.js";
|
||||
|
||||
export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
||||
app.get("/setup", () => {
|
||||
if (deps.repo.isSetupComplete()) {
|
||||
app.get("/setup", async () => {
|
||||
if (await deps.repo.isSetupComplete()) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
return htmlPage(SetupPage({}));
|
||||
});
|
||||
|
||||
app.post("/setup", async (event) => {
|
||||
if (deps.repo.isSetupComplete()) {
|
||||
if (await deps.repo.isSetupComplete()) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
|
||||
|
|
@ -38,17 +38,17 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
|||
}
|
||||
|
||||
const hash = await deps.auth.hashPassword(password);
|
||||
deps.repo.createUser({ username, password_hash: hash, role: "admin" });
|
||||
await deps.repo.createUser({ username, password_hash: hash, role: "admin" });
|
||||
|
||||
const clusterKey = deps.secrets.generateClusterKey();
|
||||
const encryptedCluster = deps.secrets.encryptString(clusterKey, "cluster");
|
||||
deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
|
||||
deps.repo.markClusterKeyProvisioned();
|
||||
await deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
|
||||
await deps.repo.markClusterKeyProvisioned();
|
||||
|
||||
// Setup only creates admin user + cluster key.
|
||||
// Displays are created when kiosks are paired (kiosk reports HDMI ports).
|
||||
// Layouts are created by admin after pairing.
|
||||
deps.repo.markSetupComplete();
|
||||
await deps.repo.markSetupComplete();
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
|
|
|
|||
|
|
@ -195,8 +195,8 @@ function extractBearerToken(event: any): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function getClusterKey(repo: Repository, secrets: SecretsApi): string | undefined {
|
||||
const enc = repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
|
||||
async function getClusterKey(repo: Repository, secrets: SecretsApi): Promise<string | undefined> {
|
||||
const enc = await repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
|
||||
if (!enc) return undefined;
|
||||
return secrets.decryptString(enc, "cluster");
|
||||
}
|
||||
|
|
@ -230,7 +230,7 @@ function registerPairingRoutes(
|
|||
managed_image?: boolean;
|
||||
}>(event);
|
||||
|
||||
const result = initiatePairing(repo, {
|
||||
const result = await initiatePairing(repo, {
|
||||
proposedName: body?.proposed_name ?? null,
|
||||
hardwareModel: body?.hardware_model ?? null,
|
||||
capabilities: body?.capabilities ?? [],
|
||||
|
|
@ -254,7 +254,7 @@ function registerPairingRoutes(
|
|||
const code = (body?.code ?? "").trim().toUpperCase();
|
||||
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });
|
||||
|
||||
const result = claimPairing(repo, code);
|
||||
const result = await claimPairing(repo, code);
|
||||
if (result.status === "pending") {
|
||||
return new Response(JSON.stringify({ status: "pending" }), {
|
||||
status: 202,
|
||||
|
|
@ -296,8 +296,8 @@ function registerKioskRoutes(
|
|||
const kiosk = await auth.verifyKioskKey(token);
|
||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||
|
||||
const clusterKey = getClusterKey(repo, secrets);
|
||||
const bundle = generateBundle(repo, secrets, kiosk.id, clusterKey);
|
||||
const clusterKey = await getClusterKey(repo, secrets);
|
||||
const bundle = await generateBundle(repo, secrets, kiosk.id, clusterKey);
|
||||
if (!bundle) throw createError({ statusCode: 404, statusMessage: "Kiosk not found" });
|
||||
|
||||
// Content-hash ETag: kiosk sends If-None-Match on subsequent fetches.
|
||||
|
|
@ -365,7 +365,7 @@ function registerKioskRoutes(
|
|||
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
|
||||
?? null;
|
||||
|
||||
repo.touchKiosk(kiosk.id, {
|
||||
await repo.touchKiosk(kiosk.id, {
|
||||
bundle_version: body?.bundle_version ?? null,
|
||||
kiosk_app_version: body?.kiosk_app_version ?? null,
|
||||
os_version: body?.os_version ?? null,
|
||||
|
|
@ -391,7 +391,7 @@ function registerKioskRoutes(
|
|||
// applied. Persist for the admin UI to render. Error string clears on a
|
||||
// successful apply (kiosk omits it). verifyKioskKey returns just {id};
|
||||
// re-read the full row to check the managed_image flag.
|
||||
const kioskFull = repo.getKioskById(kiosk.id);
|
||||
const kioskFull = await repo.getKioskById(kiosk.id);
|
||||
if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") {
|
||||
const patch: Record<string, unknown> = {
|
||||
managed_config_applied_version: body.managed_config_applied_version,
|
||||
|
|
@ -400,7 +400,7 @@ function registerKioskRoutes(
|
|||
if (body.managed_config_error !== undefined) {
|
||||
patch["managed_config_error"] = body.managed_config_error ?? null;
|
||||
}
|
||||
repo.updateKiosk(kiosk.id, patch as any);
|
||||
await repo.updateKiosk(kiosk.id, patch as any);
|
||||
}
|
||||
|
||||
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
|
||||
|
|
@ -423,7 +423,7 @@ function registerKioskRoutes(
|
|||
|
||||
// Sync displays reported by the kiosk
|
||||
if (Array.isArray(body?.displays)) {
|
||||
const existing = repo.listDisplaysForKiosk(kiosk.id);
|
||||
const existing = await repo.listDisplaysForKiosk(kiosk.id);
|
||||
const seenDisplayIds = new Set<number>();
|
||||
for (const [position, reported] of body.displays.entries()) {
|
||||
const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0
|
||||
|
|
@ -445,7 +445,7 @@ function registerKioskRoutes(
|
|||
|| match.height_px !== reported.height_px
|
||||
|| (powerState != null && match.actual_power_state !== powerState)
|
||||
) {
|
||||
repo.updateDisplay(match.id, {
|
||||
await repo.updateDisplay(match.id, {
|
||||
name: reported.name,
|
||||
index: reportedIndex,
|
||||
width_px: reported.width_px,
|
||||
|
|
@ -458,7 +458,7 @@ function registerKioskRoutes(
|
|||
}
|
||||
} else {
|
||||
// New display — create it
|
||||
const created = repo.createDisplayForKiosk(kiosk.id, {
|
||||
const created = await repo.createDisplayForKiosk(kiosk.id, {
|
||||
name: reported.name,
|
||||
index: reportedIndex,
|
||||
width_px: reported.width_px,
|
||||
|
|
@ -470,7 +470,7 @@ function registerKioskRoutes(
|
|||
? "unknown"
|
||||
: null;
|
||||
if (powerState != null) {
|
||||
repo.updateDisplay(created.id, {
|
||||
await repo.updateDisplay(created.id, {
|
||||
actual_power_state: powerState,
|
||||
actual_power_state_at: new Date().toISOString(),
|
||||
} as any);
|
||||
|
|
@ -481,14 +481,14 @@ function registerKioskRoutes(
|
|||
for (const display of existing) {
|
||||
if (seenDisplayIds.has(display.id) || !display.is_enabled) continue;
|
||||
if (!display.name.endsWith(" HDMI-0")) continue;
|
||||
if (repo.listLayoutsForDisplay(display.id).length > 0) continue;
|
||||
repo.updateDisplay(display.id, { is_enabled: false } as any);
|
||||
if ((await repo.listLayoutsForDisplay(display.id)).length > 0) continue;
|
||||
await repo.updateDisplay(display.id, { is_enabled: false } as any);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read kiosk so we see the freshly-persisted applied_version above when
|
||||
// computing whether the server still has a newer config to deliver.
|
||||
const fresh = repo.getKioskById(kiosk.id);
|
||||
const fresh = await repo.getKioskById(kiosk.id);
|
||||
let pendingConfig: { version: number; config: unknown } | undefined;
|
||||
if (
|
||||
fresh?.managed_image
|
||||
|
|
@ -552,7 +552,7 @@ function registerKioskRoutes(
|
|||
}
|
||||
}
|
||||
|
||||
const eventId = repo.insertEvent({
|
||||
const eventId = await repo.insertEvent({
|
||||
source_kiosk_id: kiosk.id,
|
||||
source_camera_id: body.camera_id ?? null,
|
||||
source_type: (body.source_type as any) ?? "system",
|
||||
|
|
@ -569,7 +569,7 @@ function registerKioskRoutes(
|
|||
const layoutId = Number(body.payload?.["layout_id"]);
|
||||
if (Number.isInteger(displayId) && Number.isInteger(layoutId)) {
|
||||
try {
|
||||
repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
|
||||
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
|
||||
} catch {
|
||||
// Display might not exist; layout.changed is best-effort telemetry.
|
||||
}
|
||||
|
|
@ -588,7 +588,7 @@ function registerKioskRoutes(
|
|||
"display.power.changed",
|
||||
"camera.changed",
|
||||
]);
|
||||
const markForwarded = () => repo.markEventForwarded(eventId);
|
||||
const markForwarded = () => { repo.markEventForwarded(eventId); };
|
||||
if (flatTopics.has(body.topic)) {
|
||||
const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}), source: "kiosk" };
|
||||
nodered.forward(body.topic, out, markForwarded);
|
||||
|
|
@ -641,7 +641,7 @@ function registerKioskRoutes(
|
|||
logged_at: e.logged_at,
|
||||
}));
|
||||
|
||||
const count = repo.insertKioskLogs(kiosk.id, entries);
|
||||
const count = await repo.insertKioskLogs(kiosk.id, entries);
|
||||
return { ok: true, count };
|
||||
});
|
||||
|
||||
|
|
@ -664,7 +664,7 @@ function registerKioskRoutes(
|
|||
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
||||
const verified = await auth.verifyKioskKey(token);
|
||||
if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||
const kiosk = repo.getKioskById(verified.id);
|
||||
const kiosk = await repo.getKioskById(verified.id);
|
||||
if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" });
|
||||
|
||||
const url = new URL(event.req.url);
|
||||
|
|
@ -677,15 +677,15 @@ function registerKioskRoutes(
|
|||
let release = null;
|
||||
// Explicit per-kiosk pin wins over all rollout / channel selection.
|
||||
if (kiosk.firmware_target_version) {
|
||||
release = repo.getFirmwareReleaseByVersionArch(kiosk.firmware_target_version, arch);
|
||||
release = await repo.getFirmwareReleaseByVersionArch(kiosk.firmware_target_version, arch);
|
||||
if (release?.yanked_at) release = null;
|
||||
}
|
||||
// Active rollouts: most-recent matching, with bucket eligibility.
|
||||
if (!release) {
|
||||
const rollouts = repo.listActiveRolloutsForKiosk(kiosk.id);
|
||||
const rollouts = await repo.listActiveRolloutsForKiosk(kiosk.id);
|
||||
for (const rollout of rollouts) {
|
||||
if (!isKioskInRolloutBucket(kiosk.id, rollout.id, rollout.percentage)) continue;
|
||||
const r = repo.getFirmwareRelease(rollout.release_id);
|
||||
const r = await repo.getFirmwareRelease(rollout.release_id);
|
||||
if (!r || r.yanked_at) continue;
|
||||
if (r.arch !== arch) continue;
|
||||
release = r;
|
||||
|
|
@ -695,7 +695,7 @@ function registerKioskRoutes(
|
|||
// Channel-latest fallback.
|
||||
if (!release) {
|
||||
const channel = (kiosk.firmware_channel ?? "stable") as FirmwareChannel;
|
||||
release = repo.getLatestFirmwareRelease(channel, arch);
|
||||
release = await repo.getLatestFirmwareRelease(channel, arch);
|
||||
}
|
||||
|
||||
if (!release || release.version === currentVersion) {
|
||||
|
|
@ -732,7 +732,7 @@ function registerKioskRoutes(
|
|||
?? new URL(event.req.url).pathname.split("/").pop();
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" });
|
||||
|
||||
const release = repo.getFirmwareRelease(id);
|
||||
const release = await repo.getFirmwareRelease(id);
|
||||
if (!release || release.yanked_at) {
|
||||
throw createError({ statusCode: 404, statusMessage: "release not found" });
|
||||
}
|
||||
|
|
@ -765,8 +765,8 @@ function registerKioskRoutes(
|
|||
if (!body?.version) {
|
||||
throw createError({ statusCode: 400, statusMessage: "version required" });
|
||||
}
|
||||
repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
|
||||
repo.insertEvent({
|
||||
await repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
|
||||
await repo.insertEvent({
|
||||
source_kiosk_id: kiosk.id,
|
||||
source_camera_id: null,
|
||||
source_type: "system",
|
||||
|
|
@ -792,7 +792,7 @@ function registerKioskRoutes(
|
|||
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
||||
const verified = await auth.verifyKioskKey(token);
|
||||
if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||
const kiosk = repo.getKioskById(verified.id);
|
||||
const kiosk = await repo.getKioskById(verified.id);
|
||||
if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" });
|
||||
|
||||
const url = new URL(event.req.url);
|
||||
|
|
@ -804,14 +804,14 @@ function registerKioskRoutes(
|
|||
|
||||
let release = null;
|
||||
if (kiosk.os_update_target_version) {
|
||||
release = repo.getOsUpdateReleaseByVersionCompatibility(kiosk.os_update_target_version, compatibility);
|
||||
release = await repo.getOsUpdateReleaseByVersionCompatibility(kiosk.os_update_target_version, compatibility);
|
||||
if (release?.yanked_at) release = null;
|
||||
}
|
||||
if (!release) {
|
||||
const rollouts = repo.listActiveOsUpdateRolloutsForKiosk(kiosk.id);
|
||||
const rollouts = await repo.listActiveOsUpdateRolloutsForKiosk(kiosk.id);
|
||||
for (const rollout of rollouts) {
|
||||
if (!isKioskInRolloutBucket(kiosk.id, rollout.id, rollout.percentage)) continue;
|
||||
const r = repo.getOsUpdateRelease(rollout.release_id);
|
||||
const r = await repo.getOsUpdateRelease(rollout.release_id);
|
||||
if (!r || r.yanked_at) continue;
|
||||
if (r.compatibility !== compatibility) continue;
|
||||
release = r;
|
||||
|
|
@ -820,7 +820,7 @@ function registerKioskRoutes(
|
|||
}
|
||||
if (!release) {
|
||||
const channel = (kiosk.os_update_channel ?? "stable") as FirmwareChannel;
|
||||
release = repo.getLatestOsUpdateRelease(channel, compatibility);
|
||||
release = await repo.getLatestOsUpdateRelease(channel, compatibility);
|
||||
}
|
||||
|
||||
if (!release || release.version === currentVersion) {
|
||||
|
|
@ -852,7 +852,7 @@ function registerKioskRoutes(
|
|||
?? new URL(event.req.url).pathname.split("/").pop();
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" });
|
||||
|
||||
const release = repo.getOsUpdateRelease(id);
|
||||
const release = await repo.getOsUpdateRelease(id);
|
||||
if (!release || release.yanked_at) {
|
||||
throw createError({ statusCode: 404, statusMessage: "release not found" });
|
||||
}
|
||||
|
|
@ -908,8 +908,8 @@ function registerKioskRoutes(
|
|||
if (!body?.version) {
|
||||
throw createError({ statusCode: 400, statusMessage: "version required" });
|
||||
}
|
||||
repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
|
||||
repo.insertEvent({
|
||||
await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
|
||||
await repo.insertEvent({
|
||||
source_kiosk_id: kiosk.id,
|
||||
source_camera_id: null,
|
||||
source_type: "system",
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
if (!authed && cookieHeader) {
|
||||
const cookieVal = parseCookieValue(cookieHeader, cookieName);
|
||||
if (cookieVal) {
|
||||
const result = auth.resolveSession(cookieVal);
|
||||
const result = await auth.resolveSession(cookieVal);
|
||||
if (result) authed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -317,7 +317,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const kioskData = repo.getKioskById(kiosk.id);
|
||||
const kioskData = await repo.getKioskById(kiosk.id);
|
||||
if (!kioskData) {
|
||||
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
||||
socket.destroy();
|
||||
|
|
|
|||
|
|
@ -162,7 +162,13 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
obs.log.info("schema up to date (version {v})", { v: currentVersion });
|
||||
}
|
||||
|
||||
this._repo = new Repository(this.db, async (table, op, id) => {
|
||||
// Wrap the already-configured DatabaseSync in a SqliteAdapter for the
|
||||
// Repository's async DbAdapter interface. Migrations already ran on
|
||||
// this.db above — SqliteAdapter just wraps it for query access.
|
||||
const { SqliteAdapter } = await import("./sqlite-adapter.js");
|
||||
const adapter = SqliteAdapter.fromExisting(this.db);
|
||||
|
||||
this._repo = new Repository(adapter, async (table, op, id) => {
|
||||
// Best-effort broadcast — never let a failed event-bus call fail a write.
|
||||
try {
|
||||
await this.events.emitBroadcast("store.changed", obs, { table, op, id });
|
||||
|
|
@ -181,12 +187,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
obs.log.info("store ready");
|
||||
}
|
||||
|
||||
private runPurge(obs: Observable): void {
|
||||
private async runPurge(obs: Observable): Promise<void> {
|
||||
if (!this._repo) return;
|
||||
const r = this._repo;
|
||||
const kl = r.purgeKioskLogs(14);
|
||||
const el = r.purgeEventLog(30, 100_000);
|
||||
const al = r.purgeAuditLog(90);
|
||||
const kl = await r.purgeKioskLogs(14);
|
||||
const el = await r.purgeEventLog(30, 100_000);
|
||||
const al = await r.purgeAuditLog(90);
|
||||
if (kl + el + al > 0) {
|
||||
obs.log.info("purge: {kl} kiosk_logs, {el} event_log, {al} audit_log", { kl, el, al });
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,15 @@ export class SqliteAdapter implements DbAdapter {
|
|||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
}
|
||||
|
||||
/** Wrap an already-opened DatabaseSync (e.g. after migrations ran). */
|
||||
static fromExisting(db: DatabaseSync): SqliteAdapter {
|
||||
const adapter = Object.create(SqliteAdapter.prototype) as SqliteAdapter;
|
||||
(adapter as any).db = db;
|
||||
(adapter as any).stmts = new Map();
|
||||
(adapter as any).txDepth = 0;
|
||||
return adapter;
|
||||
}
|
||||
|
||||
private prep(sql: string): StatementSync {
|
||||
let s = this.stmts.get(sql);
|
||||
if (!s) {
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ export interface AuditInput {
|
|||
actor_label?: string | null;
|
||||
}
|
||||
|
||||
export function audit(
|
||||
export async function audit(
|
||||
repo: Repository,
|
||||
event: AuditCtx | null,
|
||||
action: string,
|
||||
input: AuditInput = {},
|
||||
): void {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ctx = event?.context;
|
||||
let actor_type: AuditActorType = input.actor_type ?? "system";
|
||||
|
|
@ -58,7 +58,7 @@ export function audit(
|
|||
?? headers?.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
?? null;
|
||||
|
||||
repo.insertAudit({
|
||||
await repo.insertAudit({
|
||||
actor_type,
|
||||
actor_id,
|
||||
actor_label,
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ export interface AuthApi {
|
|||
ipAddress: string | null;
|
||||
totpPending: boolean;
|
||||
}): Promise<{ session: Session; cookieValue: string }>;
|
||||
resolveSession(cookieValue: string): { session: Session; user: User } | null;
|
||||
revokeSession(sid: string): void;
|
||||
resolveSession(cookieValue: string): Promise<{ session: Session; user: User } | null>;
|
||||
revokeSession(sid: string): Promise<void>;
|
||||
createApiKey(input: {
|
||||
name: string;
|
||||
scopes: ApiKeyScope[];
|
||||
|
|
@ -203,7 +203,7 @@ export function createAuth(
|
|||
const expiresAt = new Date(
|
||||
Date.now() + config.sessionMaxSeconds * 1000,
|
||||
).toISOString();
|
||||
const session = repo.createSession({
|
||||
const session = await repo.createSession({
|
||||
id,
|
||||
user_id: input.user.id,
|
||||
csrf_token: csrfToken,
|
||||
|
|
@ -215,29 +215,29 @@ export function createAuth(
|
|||
return { session, cookieValue: signCookie(id) };
|
||||
}
|
||||
|
||||
function resolveSession(
|
||||
async function resolveSession(
|
||||
cookieValue: string,
|
||||
): { session: Session; user: User } | null {
|
||||
): Promise<{ session: Session; user: User } | null> {
|
||||
const sid = unsignCookie(cookieValue);
|
||||
if (!sid) return null;
|
||||
const session = repo.getSessionById(sid);
|
||||
const session = await repo.getSessionById(sid);
|
||||
if (!session) return null;
|
||||
if (session.revoked_at) return null;
|
||||
const now = new Date();
|
||||
if (new Date(session.expires_at) <= now) return null;
|
||||
const idleMs = config.sessionIdleSeconds * 1000;
|
||||
if (now.getTime() - new Date(session.last_seen_at).getTime() > idleMs) {
|
||||
repo.revokeSession(sid);
|
||||
await repo.revokeSession(sid);
|
||||
return null;
|
||||
}
|
||||
const user = repo.getUserById(session.user_id);
|
||||
const user = await repo.getUserById(session.user_id);
|
||||
if (!user || !user.is_active) return null;
|
||||
repo.touchSession(sid, now.toISOString());
|
||||
await repo.touchSession(sid, now.toISOString());
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
function revokeSession(sid: string): void {
|
||||
repo.revokeSession(sid);
|
||||
async function revokeSession(sid: string): Promise<void> {
|
||||
await repo.revokeSession(sid);
|
||||
}
|
||||
|
||||
// ---- API keys -------------------------------------------------------------
|
||||
|
|
@ -250,7 +250,7 @@ export function createAuth(
|
|||
const plaintext = `bf-${randomBytes(24).toString("base64url")}`;
|
||||
const keyHash = await hashPassword(plaintext);
|
||||
const keyPrefix = plaintext.slice(0, 8);
|
||||
const apiKey = repo.createApiKey({
|
||||
const apiKey = await repo.createApiKey({
|
||||
name: input.name,
|
||||
key_hash: keyHash,
|
||||
key_prefix: keyPrefix,
|
||||
|
|
@ -262,12 +262,12 @@ export function createAuth(
|
|||
|
||||
async function verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null> {
|
||||
const prefix = plaintext.slice(0, 8);
|
||||
const candidates = repo.listApiKeysByPrefix(prefix);
|
||||
const candidates = await repo.listApiKeysByPrefix(prefix);
|
||||
for (const cand of candidates) {
|
||||
if (cand.revoked_at) continue;
|
||||
if (cand.expires_at && new Date(cand.expires_at) <= new Date()) continue;
|
||||
if (await verifyPassword(plaintext, cand.key_hash)) {
|
||||
repo.touchApiKey(cand.id, ip);
|
||||
await repo.touchApiKey(cand.id, ip);
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
|
|
@ -277,7 +277,7 @@ export function createAuth(
|
|||
async function verifyKioskKey(plaintext: string): Promise<{ id: number } | null> {
|
||||
if (plaintext.length < 8) return null;
|
||||
const prefix = plaintext.slice(0, 8);
|
||||
const candidates = repo.listKiosksByKeyPrefix(prefix);
|
||||
const candidates = await repo.listKiosksByKeyPrefix(prefix);
|
||||
for (const cand of candidates) {
|
||||
if (await verifyPassword(plaintext, cand.key_hash)) {
|
||||
return { id: cand.id };
|
||||
|
|
|
|||
|
|
@ -106,13 +106,13 @@ export interface KioskBundle {
|
|||
version: string;
|
||||
}
|
||||
|
||||
export function generateBundle(
|
||||
export async function generateBundle(
|
||||
repo: Repository,
|
||||
secrets: SecretsApi,
|
||||
kioskId: number,
|
||||
clusterKey: string | undefined,
|
||||
): KioskBundle | null {
|
||||
const kiosk = repo.getKioskById(kioskId);
|
||||
): Promise<KioskBundle | null> {
|
||||
const kiosk = await repo.getKioskById(kioskId);
|
||||
if (!kiosk) return null;
|
||||
|
||||
// Per-kiosk encryption key (preferred) — decrypt from server storage.
|
||||
|
|
@ -126,11 +126,11 @@ export function generateBundle(
|
|||
}
|
||||
|
||||
// Find all displays for this kiosk (displays now point to kiosks via kiosk_id)
|
||||
const kioskDisplays = repo.listDisplaysForKiosk(kioskId);
|
||||
const kioskDisplays = await repo.listDisplaysForKiosk(kioskId);
|
||||
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet
|
||||
const allDisplays = kioskDisplays.length > 0
|
||||
? kioskDisplays
|
||||
: (kiosk.display_id ? [repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable<typeof d> => d != null) : []);
|
||||
: (kiosk.display_id ? [await repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable<typeof d> => d != null) : []);
|
||||
|
||||
// Admin can disable a display — kiosk must never open a window on it.
|
||||
const displays = allDisplays.filter((d) => d.is_enabled);
|
||||
|
|
@ -139,14 +139,15 @@ export function generateBundle(
|
|||
// Collect camera IDs across ALL displays' layouts (de-duped).
|
||||
const allLayoutIds = new Set<number>();
|
||||
for (const d of displays) {
|
||||
for (const l of repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id);
|
||||
for (const l of await repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id);
|
||||
}
|
||||
const cameras = repo.camerasForLayoutIds([...allLayoutIds]);
|
||||
const cameras = await repo.camerasForLayoutIds([...allLayoutIds]);
|
||||
|
||||
function buildLayouts(displayId: number, defaultLayoutId: number | null): BundleLayout[] {
|
||||
const layouts = repo.layoutsForDisplayId(displayId);
|
||||
return layouts.map((l) => {
|
||||
const cells = repo.layoutCells(l.id);
|
||||
async function buildLayouts(displayId: number, defaultLayoutId: number | null): Promise<BundleLayout[]> {
|
||||
const layouts = await repo.layoutsForDisplayId(displayId);
|
||||
const result: BundleLayout[] = [];
|
||||
for (const l of layouts) {
|
||||
const cells = await repo.layoutCells(l.id);
|
||||
let gridCols = 1;
|
||||
let gridRows = 1;
|
||||
for (const c of cells) {
|
||||
|
|
@ -155,7 +156,46 @@ export function generateBundle(
|
|||
if (right > gridCols) gridCols = right;
|
||||
if (bottom > gridRows) gridRows = bottom;
|
||||
}
|
||||
return {
|
||||
const bundleCells: BundleCell[] = [];
|
||||
for (const c of cells) {
|
||||
// If the cell has an entity, prefer its current content so admin
|
||||
// edits to the entity propagate without forcing a cell-touch. The
|
||||
// bundle still ships the legacy camera_id/web_url/html_content shape
|
||||
// so the existing Rust kiosk consumes it unchanged.
|
||||
let contentType = c.content_type;
|
||||
let cameraId = c.camera_id;
|
||||
let webUrl = c.web_url;
|
||||
let htmlContent = c.html_content;
|
||||
if (c.entity_id != null) {
|
||||
const ent = await repo.getEntityById(c.entity_id);
|
||||
if (ent) {
|
||||
// Dashboard entities are surfaced to the kiosk as `web` cells
|
||||
// pointing at /dash/<dashboard_id> — kiosk WebKit handles them
|
||||
// identically to user-supplied web cells.
|
||||
contentType = ent.type === "dashboard" ? "web" : ent.type;
|
||||
cameraId = ent.type === "camera" ? ent.camera_id : null;
|
||||
webUrl =
|
||||
ent.type === "web" ? ent.web_url :
|
||||
ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` :
|
||||
null;
|
||||
htmlContent = ent.type === "html" ? ent.html_content : null;
|
||||
}
|
||||
}
|
||||
bundleCells.push({
|
||||
row: c.row,
|
||||
col: c.col,
|
||||
row_span: c.row_span,
|
||||
col_span: c.col_span,
|
||||
content_type: contentType,
|
||||
camera_id: cameraId,
|
||||
stream_selector: c.stream_selector,
|
||||
web_url: webUrl,
|
||||
html_content: htmlContent,
|
||||
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
||||
fit: c.fit,
|
||||
});
|
||||
}
|
||||
result.push({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
grid_cols: gridCols,
|
||||
|
|
@ -165,61 +205,29 @@ export function generateBundle(
|
|||
preload_camera_ids: l.preload_camera_ids,
|
||||
resets_idle_timer: l.resets_idle_timer,
|
||||
is_default: defaultLayoutId === l.id,
|
||||
cells: cells.map((c) => {
|
||||
// If the cell has an entity, prefer its current content so admin
|
||||
// edits to the entity propagate without forcing a cell-touch. The
|
||||
// bundle still ships the legacy camera_id/web_url/html_content shape
|
||||
// so the existing Rust kiosk consumes it unchanged.
|
||||
let contentType = c.content_type;
|
||||
let cameraId = c.camera_id;
|
||||
let webUrl = c.web_url;
|
||||
let htmlContent = c.html_content;
|
||||
if (c.entity_id != null) {
|
||||
const ent = repo.getEntityById(c.entity_id);
|
||||
if (ent) {
|
||||
// Dashboard entities are surfaced to the kiosk as `web` cells
|
||||
// pointing at /dash/<dashboard_id> — kiosk WebKit handles them
|
||||
// identically to user-supplied web cells.
|
||||
contentType = ent.type === "dashboard" ? "web" : ent.type;
|
||||
cameraId = ent.type === "camera" ? ent.camera_id : null;
|
||||
webUrl =
|
||||
ent.type === "web" ? ent.web_url :
|
||||
ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` :
|
||||
null;
|
||||
htmlContent = ent.type === "html" ? ent.html_content : null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
row: c.row,
|
||||
col: c.col,
|
||||
row_span: c.row_span,
|
||||
col_span: c.col_span,
|
||||
content_type: contentType,
|
||||
camera_id: cameraId,
|
||||
stream_selector: c.stream_selector,
|
||||
web_url: webUrl,
|
||||
html_content: htmlContent,
|
||||
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
||||
fit: c.fit,
|
||||
};
|
||||
}),
|
||||
};
|
||||
cells: bundleCells,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const bundleDisplays: BundleDisplayWithLayouts[] = [];
|
||||
for (const display of displays) {
|
||||
bundleDisplays.push({
|
||||
id: display.id,
|
||||
name: display.name,
|
||||
width_px: display.width_px,
|
||||
height_px: display.height_px,
|
||||
idle_timeout_seconds: display.idle_timeout_seconds,
|
||||
sleep_timeout_seconds: display.sleep_timeout_seconds,
|
||||
default_layout_id: display.default_layout_id,
|
||||
layouts: await buildLayouts(display.id, display.default_layout_id),
|
||||
});
|
||||
}
|
||||
|
||||
const bundleDisplays: BundleDisplayWithLayouts[] = displays.map((display) => ({
|
||||
id: display.id,
|
||||
name: display.name,
|
||||
width_px: display.width_px,
|
||||
height_px: display.height_px,
|
||||
idle_timeout_seconds: display.idle_timeout_seconds,
|
||||
sleep_timeout_seconds: display.sleep_timeout_seconds,
|
||||
default_layout_id: display.default_layout_id,
|
||||
layouts: buildLayouts(display.id, display.default_layout_id),
|
||||
}));
|
||||
|
||||
const bundleCameras: BundleCamera[] = cameras.map((cam) => {
|
||||
const streams = repo.listCameraStreams(cam.id);
|
||||
const bundleCameras: BundleCamera[] = [];
|
||||
for (const cam of cameras) {
|
||||
const streams = await repo.listCameraStreams(cam.id);
|
||||
const effectiveStreams = streams.length > 0 ? streams : (
|
||||
cam.type === "rtsp" && cam.rtsp_url
|
||||
? [{
|
||||
|
|
@ -243,7 +251,7 @@ export function generateBundle(
|
|||
if (cam.onvif_password && encryptKey) {
|
||||
onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, encryptKey);
|
||||
}
|
||||
return {
|
||||
bundleCameras.push({
|
||||
id: cam.id,
|
||||
name: cam.name,
|
||||
type: cam.type,
|
||||
|
|
@ -265,10 +273,10 @@ export function generateBundle(
|
|||
encoding: s.encoding,
|
||||
framerate: s.framerate,
|
||||
})),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const gpioBindings: BundleGpioBinding[] = repo.listGpioBindings(kioskId).map((g) => ({
|
||||
const gpioBindings: BundleGpioBinding[] = (await repo.listGpioBindings(kioskId)).map((g) => ({
|
||||
id: g.id,
|
||||
chip: g.chip,
|
||||
pin: g.pin,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function startCameraHealthChecker(
|
|||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function checkAll(): Promise<void> {
|
||||
const cameras = repo.listCameras().filter((c) => c.enabled);
|
||||
const cameras = (await repo.listCameras()).filter((c) => c.enabled);
|
||||
for (const cam of cameras) {
|
||||
const host = cam.type === "onvif"
|
||||
? cam.onvif_host
|
||||
|
|
@ -48,12 +48,12 @@ export function startCameraHealthChecker(
|
|||
: false;
|
||||
|
||||
if (reachable) {
|
||||
repo.updateCamera(cam.id, { last_seen_at: new Date().toISOString() } as any);
|
||||
await repo.updateCamera(cam.id, { last_seen_at: new Date().toISOString() } as any);
|
||||
} else if (wasOnline) {
|
||||
// Camera just went offline — log event for Node-RED / admin visibility.
|
||||
log.warn(`camera ${cam.id} (${cam.name}) went offline`);
|
||||
try {
|
||||
repo.insertEvent({
|
||||
await repo.insertEvent({
|
||||
source_kiosk_id: null,
|
||||
source_camera_id: cam.id,
|
||||
source_type: "system",
|
||||
|
|
|
|||
|
|
@ -38,21 +38,21 @@ export interface PairingInitiateResult {
|
|||
expiresAt: string;
|
||||
}
|
||||
|
||||
export function initiatePairing(
|
||||
export async function initiatePairing(
|
||||
repo: Repository,
|
||||
input: PairingInitiateInput,
|
||||
): PairingInitiateResult {
|
||||
): Promise<PairingInitiateResult> {
|
||||
let code: string;
|
||||
let attempts = 0;
|
||||
do {
|
||||
code = generateCode();
|
||||
attempts++;
|
||||
if (attempts > 20) throw new Error("failed to generate unique pairing code");
|
||||
} while (repo.getPairingCode(code) !== null);
|
||||
} while (await repo.getPairingCode(code) !== null);
|
||||
|
||||
const expiresAt = new Date(Date.now() + input.codeTtlSeconds * 1000).toISOString();
|
||||
|
||||
repo.createPairingCode({
|
||||
await repo.createPairingCode({
|
||||
code,
|
||||
kiosk_proposed_name: input.proposedName,
|
||||
kiosk_hardware_model: input.hardwareModel,
|
||||
|
|
@ -73,11 +73,11 @@ export interface PairingClaimResult {
|
|||
bundleUrl?: string;
|
||||
}
|
||||
|
||||
export function claimPairing(
|
||||
export async function claimPairing(
|
||||
repo: Repository,
|
||||
code: string,
|
||||
): PairingClaimResult {
|
||||
const pc = repo.getPairingCode(code);
|
||||
): Promise<PairingClaimResult> {
|
||||
const pc = await repo.getPairingCode(code);
|
||||
if (!pc) return { status: "pending" };
|
||||
if (new Date(pc.expires_at) < new Date()) return { status: "pending" };
|
||||
if (!pc.consumed_at) return { status: "pending" };
|
||||
|
|
@ -87,11 +87,11 @@ export function claimPairing(
|
|||
|
||||
if (!kioskKey || !pc.consumed_by_kiosk_id) return { status: "pending" };
|
||||
|
||||
const kiosk = repo.getKioskById(pc.consumed_by_kiosk_id);
|
||||
const kiosk = await repo.getKioskById(pc.consumed_by_kiosk_id);
|
||||
const clusterKey = extras["cluster_key"] as string | undefined;
|
||||
|
||||
// Wipe plaintext key from extras after first claim
|
||||
repo.updatePairingCodeExtras(code, { ...extras, kiosk_key_plaintext: undefined, cluster_key: undefined });
|
||||
await repo.updatePairingCodeExtras(code, { ...extras, kiosk_key_plaintext: undefined, cluster_key: undefined });
|
||||
|
||||
return {
|
||||
status: "claimed",
|
||||
|
|
@ -124,7 +124,7 @@ export async function confirmPairing(
|
|||
secrets: SecretsApi,
|
||||
input: PairingConfirmInput,
|
||||
): Promise<{ kioskId: number; kioskName: string }> {
|
||||
const pc = repo.getPairingCode(input.code);
|
||||
const pc = await repo.getPairingCode(input.code);
|
||||
if (!pc) throw new Error("pairing code not found");
|
||||
if (pc.consumed_at) throw new Error("pairing code already used");
|
||||
if (new Date(pc.expires_at) < new Date()) throw new Error("pairing code expired");
|
||||
|
|
@ -137,7 +137,7 @@ export async function confirmPairing(
|
|||
let kioskName: string;
|
||||
|
||||
if (input.replaceKioskId != null) {
|
||||
const existing = repo.getKioskById(input.replaceKioskId);
|
||||
const existing = await repo.getKioskById(input.replaceKioskId);
|
||||
if (!existing) throw new Error("replacement target kiosk not found");
|
||||
|
||||
// Sanity-check the incoming device matches the slot it's replacing.
|
||||
|
|
@ -166,7 +166,7 @@ export async function confirmPairing(
|
|||
}
|
||||
}
|
||||
|
||||
repo.replaceKioskKey(existing.id, {
|
||||
await repo.replaceKioskKey(existing.id, {
|
||||
key_hash: kioskKeyHash,
|
||||
key_prefix: kioskKeyPrefix,
|
||||
capabilities: pc.kiosk_capabilities,
|
||||
|
|
@ -176,7 +176,7 @@ export async function confirmPairing(
|
|||
// capabilities/hw, but the explicit column is updated separately because
|
||||
// replaceKioskKey doesn't touch it).
|
||||
if (existing.managed_image !== (pc.extras?.["managed_image"] === true)) {
|
||||
repo.updateKiosk(existing.id, { managed_image: pc.extras?.["managed_image"] === true } as any);
|
||||
await repo.updateKiosk(existing.id, { managed_image: pc.extras?.["managed_image"] === true } as any);
|
||||
}
|
||||
kioskId = existing.id;
|
||||
kioskName = existing.name;
|
||||
|
|
@ -184,13 +184,13 @@ export async function confirmPairing(
|
|||
const baseName = input.nameOverride || pc.kiosk_proposed_name || `kiosk-${input.code.toLowerCase()}`;
|
||||
let candidate = baseName;
|
||||
let suffix = 2;
|
||||
while (repo.getKioskByName(candidate)) {
|
||||
while (await repo.getKioskByName(candidate)) {
|
||||
candidate = `${baseName}-${suffix}`;
|
||||
suffix++;
|
||||
if (suffix > 100) throw new Error("could not generate unique kiosk name");
|
||||
}
|
||||
|
||||
const kiosk = repo.createKiosk({
|
||||
const kiosk = await repo.createKiosk({
|
||||
name: candidate,
|
||||
key_hash: kioskKeyHash,
|
||||
key_prefix: kioskKeyPrefix,
|
||||
|
|
@ -199,7 +199,7 @@ export async function confirmPairing(
|
|||
managed_image: pc.extras?.["managed_image"] === true,
|
||||
});
|
||||
|
||||
repo.createDisplayForKiosk(kiosk.id, {
|
||||
await repo.createDisplayForKiosk(kiosk.id, {
|
||||
name: `${candidate} HDMI-0`,
|
||||
});
|
||||
|
||||
|
|
@ -207,8 +207,8 @@ export async function confirmPairing(
|
|||
for (const labelName of input.initialLabels) {
|
||||
const trimmed = labelName.trim().toLowerCase();
|
||||
if (!trimmed) continue;
|
||||
const label = repo.ensureLabel(trimmed);
|
||||
repo.attachKioskLabel(kiosk.id, label.id, "consume");
|
||||
const label = await repo.ensureLabel(trimmed);
|
||||
await repo.attachKioskLabel(kiosk.id, label.id, "consume");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,15 +221,15 @@ export async function confirmPairing(
|
|||
// kiosk (one-time). Replaces shared cluster_key for bundle encryption.
|
||||
const kioskEncryptKey = randomBytes(32).toString("base64url");
|
||||
const kioskEncryptKeyEncrypted = secrets.encryptString(kioskEncryptKey, "kiosk-encrypt");
|
||||
repo.updateKiosk(kioskId, { encrypt_key_encrypted: kioskEncryptKeyEncrypted } as any);
|
||||
await repo.updateKiosk(kioskId, { encrypt_key_encrypted: kioskEncryptKeyEncrypted } as any);
|
||||
|
||||
// Still deliver cluster_key for backward compat (old kiosk binaries
|
||||
// that don't understand encrypt_key yet). Remove once all kiosks are
|
||||
// on the new binary.
|
||||
const clusterKeyEncrypted = repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
|
||||
const clusterKeyEncrypted = await repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
|
||||
const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : undefined;
|
||||
|
||||
repo.markPairingCodeClaimed(input.code, kioskId, {
|
||||
await repo.markPairingCodeClaimed(input.code, kioskId, {
|
||||
kiosk_key_plaintext: kioskKeyPlaintext,
|
||||
cluster_key: clusterKey,
|
||||
encrypt_key: kioskEncryptKey,
|
||||
|
|
|
|||
Loading…
Reference in a new issue