mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-24 08:32:13 +03:00
Compare commits
3 Commits
fix/data-d
...
fix/14060-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1486e6116d | ||
|
|
9f82134355 | ||
|
|
7064cca334 |
1
changelog.d/fixes/14060-malformed-sqlite-500.md
Normal file
1
changelog.d/fixes/14060-malformed-sqlite-500.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): Home degrades to default settings instead of returning 500 when the SQLite key_value table is corrupted; getSettings() keeps failing closed so auth gates still require login (#14060)
|
||||
25
src/app/(dashboard)/home/loadHomeSettings.ts
Normal file
25
src/app/(dashboard)/home/loadHomeSettings.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
|
||||
export type HomeSettings = { setupComplete?: unknown };
|
||||
|
||||
/**
|
||||
* Settings read for the Home Server Component (#14060).
|
||||
*
|
||||
* A corrupted `key_value` table makes getSettings() throw, which used to crash the
|
||||
* whole Home render with a 500. The degradation lives HERE, in the display-only
|
||||
* consumer — never inside getSettings() itself: auth/authz callers such as
|
||||
* isAuthRequired() rely on getSettings() rejecting so they fail CLOSED. Swallowing
|
||||
* the error at the DB layer would hand them password-less defaults and disable
|
||||
* auth for loopback requests.
|
||||
*/
|
||||
export async function loadHomeSettings(
|
||||
load: () => Promise<HomeSettings> = getSettings
|
||||
): Promise<HomeSettings> {
|
||||
try {
|
||||
return await load();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[Home] Failed to load settings; rendering with defaults: ${message}`);
|
||||
return { setupComplete: false };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { loadHomeSettings } from "./loadHomeSettings";
|
||||
import HomePageClient from "../dashboard/HomePageClient";
|
||||
import BootstrapBanner from "../dashboard/BootstrapBanner";
|
||||
import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";
|
||||
@@ -11,9 +11,8 @@ import FirstRunReadinessCard from "../dashboard/FirstRunReadinessCard";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
// Even if getSettings() rejects, getMachineId() runs concurrently, which is acceptable
|
||||
// as both paths fail-fast on error and avoids the waterfall penalty.
|
||||
const [settings, machineId] = await Promise.all([getSettings(), getMachineId()]);
|
||||
// Settings failures degrade to defaults here (display-only) — see loadHomeSettings (#14060).
|
||||
const [settings, machineId] = await Promise.all([loadHomeSettings(), getMachineId()]);
|
||||
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
|
||||
return (
|
||||
<>
|
||||
|
||||
85
tests/unit/settings-14060-getsettings-corrupt-db.test.ts
Normal file
85
tests/unit/settings-14060-getsettings-corrupt-db.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-issue-14060-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const apiAuth = await import("../../src/shared/utils/apiAuth.ts");
|
||||
const { loadHomeSettings } = await import("../../src/app/(dashboard)/home/loadHomeSettings.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
/** Overwrite only the key_value table's root page so the boot probe tables stay readable. */
|
||||
function corruptKeyValueTable() {
|
||||
const db = core.getDbInstance();
|
||||
const dbPath = (db as { name?: string }).name;
|
||||
assert.ok(typeof dbPath === "string" && dbPath.length > 0, "expected a file-backed db path");
|
||||
|
||||
const pageSize = (db.pragma("page_size") as Array<{ page_size: number }>)[0].page_size;
|
||||
const rootPageRow = db
|
||||
.prepare("SELECT rootpage FROM sqlite_master WHERE type = 'table' AND name = 'key_value'")
|
||||
.get() as { rootpage: number } | undefined;
|
||||
assert.ok(rootPageRow?.rootpage, "expected to find key_value's rootpage in sqlite_master");
|
||||
|
||||
core.resetDbInstance();
|
||||
|
||||
const buf = fs.readFileSync(dbPath as string);
|
||||
const pageStart = (rootPageRow!.rootpage - 1) * pageSize;
|
||||
for (let i = pageStart; i < Math.min(buf.length, pageStart + pageSize); i++) {
|
||||
buf[i] = 0xff;
|
||||
}
|
||||
fs.writeFileSync(dbPath as string, buf);
|
||||
|
||||
const reopened = core.getDbInstance();
|
||||
const bootHealthy = !!reopened
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'provider_connections'"
|
||||
)
|
||||
.get();
|
||||
assert.equal(bootHealthy, true, "expected the boot probe's own tables to remain readable");
|
||||
core.resetDbInstance();
|
||||
}
|
||||
|
||||
test("#14060: corrupted key_value keeps auth fail-closed and Home degrades without crashing", async () => {
|
||||
corruptKeyValueTable();
|
||||
|
||||
// (a) Auth gate stays fail-closed: a loopback request on a protected path, with no
|
||||
// password configured, must still require auth when settings cannot be read.
|
||||
// Swallowing the error inside getSettings() returned password-less defaults and
|
||||
// made this `false` (auth disabled for loopback).
|
||||
const loopbackRequest = new Request("http://localhost:20128/api/providers");
|
||||
assert.equal(
|
||||
await apiAuth.isAuthRequired(loopbackRequest, { loopback: true }),
|
||||
true,
|
||||
"isAuthRequired() must fail closed when settings are unreadable"
|
||||
);
|
||||
// The first-password bootstrap write must not open up either.
|
||||
const bootstrapWrite = new Request("http://localhost:20128/api/settings/require-login", {
|
||||
method: "POST",
|
||||
});
|
||||
assert.equal(await apiAuth.isAuthRequired(bootstrapWrite, { loopback: true }), true);
|
||||
|
||||
// getSettings() must keep surfacing the read error — its auth callers depend on it.
|
||||
await assert.rejects(() => settingsDb.getSettings());
|
||||
|
||||
// (b) Home degrades to defaults instead of rejecting (the original 500).
|
||||
const homeSettings = await loadHomeSettings();
|
||||
assert.deepEqual(homeSettings, { setupComplete: false });
|
||||
});
|
||||
|
||||
test("loadHomeSettings passes healthy settings through untouched", async () => {
|
||||
const result = await loadHomeSettings(async () => ({ setupComplete: true }));
|
||||
assert.deepEqual(result, { setupComplete: true });
|
||||
});
|
||||
Reference in New Issue
Block a user