feat(database): add account-scoped database manager

This commit is contained in:
Codex committed 2026-09-28 18:55:30 +08:00
1 parent 1606c41f43
commit 33e80a566e
18 files changed
+444 -25

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
import { cloudApi } from "./cloud-state.js";
const TABLES = {
memories: "lifeos_memories", goals: "lifeos_goals", tasks: "lifeos_tasks",
museum: "lifeos_favorites", profile: "lifeos_meta",
settings: "lifeos_meta", system: "lifeos_meta"
};
const META = new Set(["profile", "settings", "system"]);
const DURATION = 20 * 60 * 1000;
const body = (value, status = 200) => Response.json(value, { status, headers: { "cache-control": "no-store" } });
const validId = value => typeof value === "string" && value.length > 0 && value.length <= 200;
const hash = async value => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))].map(byte => byte.toString(16).padStart(2, "0")).join("");
const equal = async (a, b) => {
const [first, second] = await Promise.all([hash(a), hash(b)]);
let difference = 0;
for (let i = 0; i < first.length; i++) difference |= first.charCodeAt(i) ^ second.charCodeAt(i);
return difference === 0;
};
const token = () => {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
};
async function signedIn(request, db, userId) {
const raw = request.headers.get("x-lifeos-admin-session");
if (!raw || raw.length > 100) return false;
const record = await db.prepare("SELECT expires_at FROM lifeos_admin_sessions WHERE token_hash = ? AND user_id = ?").bind(await hash(raw), userId).first();
return !!record && record.expires_at > Date.now();
}
function originAllowed(request) {
const origin = request.headers.get("origin");
return !origin || origin === new URL(request.url).origin;
}
async function login(request, env, userId) {
const secret = env.LIFEOS_DB_ADMIN_PASSWORD;
if (typeof secret !== "string" || secret.length < 20) return body({ error: "Admin login is not configured" }, 503);
const row = await env.DB.prepare("SELECT failures, locked_until FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).first();
if (row?.locked_until > Date.now()) return body({ error: "Too many attempts", retryAt: row.locked_until }, 429);
const text = await request.text();
if (text.length > 1024) return body({ error: "Invalid credentials" }, 400);
let input;
try { input = JSON.parse(text); } catch { return body({ error: "Invalid JSON" }, 400); }
if (input?.acknowledged !== true) return body({ error: "Direct database access must be acknowledged" }, 400);
const matched = input.username === "lifeos-admin" && typeof input.password === "string" &&
await equal(input.password, secret);
if (!matched) {
const failures = (row?.failures || 0) + 1, until = failures >= 5 ? Date.now() + 15 * 60_000 : 0;
await env.DB.prepare("INSERT INTO lifeos_admin_attempts(user_id, failures, locked_until) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET failures = excluded.failures, locked_until = excluded.locked_until").bind(userId, failures >= 5 ? 0 : failures, until).run();
return body({ error: "Invalid credentials", retryAt: until || null }, 401);
}
await env.DB.prepare("DELETE FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).run();
const raw = token(), expiresAt = Date.now() + DURATION;
await env.DB.prepare("INSERT INTO lifeos_admin_sessions(token_hash, user_id, expires_at) VALUES (?, ?, ?)").bind(await hash(raw), userId, expiresAt).run();
return body({ token: raw, expiresAt });
}
async function list(db, userId, kind, page) {
const table = TABLES[kind], offset = page * 50;
const filter = META.has(kind) ? "user_id = ? AND kind = ?" : "user_id = ? AND deleted_at IS NULL";
const bindings = META.has(kind) ? [userId, kind] : [userId];
const rows = await db.prepare(`SELECT ${META.has(kind) ? "kind" : "id"} AS id, value_json, revision, updated_at FROM ${table} WHERE ${filter} ORDER BY updated_at DESC LIMIT 50 OFFSET ?`).bind(...bindings, offset).all();
const count = await db.prepare(`SELECT COUNT(*) AS total FROM ${table} WHERE ${filter}`).bind(...bindings).first();
return body({ rows: rows.results.map(row => ({
id: row.id, value: JSON.parse(row.value_json), revision: row.revision, updatedAt: row.updated_at
})), total: count.total, page });
}
async function mutate(request, env, userId, kind, id, method) {
if (!validId(id) || (META.has(kind) && id !== kind)) return body({ error: "Invalid record ID" }, 400);
let input = {};
if (method !== "DELETE") {
const text = await request.text();
if (text.length > 1_100_000) return body({ error: "Record is too large" }, 413);
try { input = JSON.parse(text); } catch { return body({ error: "Invalid JSON" }, 400); }
} else {
input.baseVersion = Number(new URL(request.url).searchParams.get("version"));
}
const baseVersion = input.baseVersion;
if (!Number.isInteger(baseVersion) || baseVersion < 0 || (method !== "POST" && baseVersion === 0)) return body({ error: "Invalid revision" }, 400);
if (method === "POST" && baseVersion !== 0) return body({ error: "New records start at revision zero" }, 400);
if (method === "DELETE" && META.has(kind)) {
const result = await env.DB.prepare("DELETE FROM lifeos_meta WHERE user_id = ? AND kind = ? AND revision = ?").bind(userId, kind, baseVersion).run();
return result.meta.changes ? body({ ok: true }) : body({ error: "Conflict" }, 409);
}
if (method !== "DELETE" && (!input.value || typeof input.value !== "object" || Array.isArray(input.value) ||
(!META.has(kind) && input.value.id !== id))) return body({ error: "Invalid record JSON" }, 400);
return cloudApi(new Request(new URL("/api/lifeos-sync", request.url), {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ kind, id, baseVersion, value: method === "DELETE" ? null : input.value })
}), env, userId, "/api/lifeos-sync");
}
export async function dbAdminApi(request, env, userId, pathname) {
if (!userId) return body({ error: "Authentication required" }, 401);
if (!env.DB) return body({ error: "Database unavailable" }, 503);
if (!originAllowed(request)) return body({ error: "Origin rejected" }, 403);
const segments = pathname.slice("/api/db-admin".length).split("/").filter(Boolean);
try {
if (segments[0] === "login" && segments.length === 1 && request.method === "POST") return login(request, env, userId);
if (!await signedIn(request, env.DB, userId)) return body({ error: "Admin session required" }, 401);
if (segments[0] === "session" && segments.length === 1 && request.method === "GET") return body({ ok: true });
if (segments[0] === "logout" && segments.length === 1 && request.method === "POST") {
await env.DB.prepare("DELETE FROM lifeos_admin_sessions WHERE token_hash = ? AND user_id = ?").bind(await hash(request.headers.get("x-lifeos-admin-session")), userId).run();
return body({ ok: true });
}
const kind = segments[1];
if (segments[0] !== "tables" || !Object.hasOwn(TABLES, kind)) return body({ error: "Unknown table" }, 404);
if (segments.length === 3 && segments[2] === "rows" && request.method === "GET") {
const page = Number(new URL(request.url).searchParams.get("page") || 0);
if (!Number.isInteger(page) || page < 0 || page > 1000) return body({ error: "Invalid page" }, 400);
return list(env.DB, userId, kind, page);
}
if (segments.length === 4 && segments[2] === "rows" && ["POST", "PATCH", "DELETE"].includes(request.method)) {
return mutate(request, env, userId, kind, decodeURIComponent(segments[3]), request.method);
}
return body({ error: "Method not allowed" }, 405);
} catch (error) {
console.error("Life OS database manager failed", error);
return body({ error: "Database operation failed" }, 500);
}
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, browserAppJs, dataActionsJs, terminalAppJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js";
import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, browserAppJs, dataActionsJs, terminalAppJs, dbAdminJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js";
import { cloudApi } from "./cloud-state.js";
import { dbAdminApi } from "./db-admin.js";
const BING_ARCHIVE = "https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US";
@@ -26,6 +27,7 @@ export default {
const url = new URL(request.url);
const id = userId(request);
if (["/api/lifeos-state", "/api/lifeos-initialize", "/api/lifeos-sync"].includes(url.pathname)) return cloudApi(request, env, id, url.pathname);
if (url.pathname.startsWith("/api/db-admin/")) return dbAdminApi(request, env, id, url.pathname);
try {
if (url.pathname === "/api/bing-wallpaper") return Response.json(await bingMetadata(), { headers: { "cache-control": "public, max-age=3600" } });
if (url.pathname === "/api/bing-wallpaper-image") {
@@ -49,6 +51,7 @@ export default {
if (url.pathname === "/calendar.js") return new Response(calendarJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") });
if (url.pathname === "/data-actions.js") return new Response(dataActionsJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") });
if (url.pathname === "/terminal-app.js") return new Response(terminalAppJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") });
if (url.pathname === "/db-admin.js") return new Response(dbAdminJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") });
if (url.pathname === "/browser-app.js") return new Response(browserAppJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") });
if (url.pathname === "/i18n-renderers.js") return new Response(i18nRenderersJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") });
if (url.pathname === "/" || url.pathname === "/index.html") {