update
This commit is contained in:
@@ -108,6 +108,11 @@ export function getDb(): Database {
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings(
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
// Email column added after the initial schema — keep idempotent.
|
||||
const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[];
|
||||
@@ -332,6 +337,29 @@ export function deleteUserResets(userId: string): void {
|
||||
getDb().query("DELETE FROM resets WHERE user_id = ?").run(userId);
|
||||
}
|
||||
|
||||
// --- platform settings (admin-controlled feature flags) ---
|
||||
|
||||
export function getSetting(key: string, fallback: string): string {
|
||||
const r = getDb().query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null;
|
||||
return r?.value ?? fallback;
|
||||
}
|
||||
|
||||
export function setSetting(key: string, value: string): void {
|
||||
getDb().query(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||
).run(key, value, Date.now());
|
||||
}
|
||||
|
||||
/** Public self-registration switch (default off = admin-created accounts only). */
|
||||
export function isRegistrationEnabled(): boolean {
|
||||
return getSetting("registration_enabled", "0") === "1";
|
||||
}
|
||||
|
||||
/** Admin kill-switch for the OCR platform tool (default on). */
|
||||
export function isOcrToolEnabled(): boolean {
|
||||
return getSetting("tools_ocr_enabled", "1") === "1";
|
||||
}
|
||||
|
||||
// --- public shares ---
|
||||
|
||||
export function ensureShare(convId: string): string {
|
||||
|
||||
+194
-1
@@ -28,11 +28,14 @@ import {
|
||||
getMessages,
|
||||
getShareByConv,
|
||||
getShareByPublic,
|
||||
getSetting,
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
isRegistrationEnabled,
|
||||
listConversations,
|
||||
listUsers,
|
||||
peekReset,
|
||||
setSetting,
|
||||
toPublicUser,
|
||||
touchConversation,
|
||||
updateConversation,
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
import type { ChatMessage } from "./drivers/types.ts";
|
||||
import { DriverRegistry } from "./drivers/registry.ts";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { statSync } from "node:fs";
|
||||
import { mailEnabled, sendRecoveryEmail } from "./mail.ts";
|
||||
import {
|
||||
CDN_NAMESPACES,
|
||||
@@ -61,6 +65,8 @@ import { tools } from "./tools/registry.ts";
|
||||
|
||||
assertConfig();
|
||||
const registry = new DriverRegistry();
|
||||
// Ollama handles admin model pulls/deletes (host is loopback/LAN, no auth).
|
||||
const ollamaDriver = registry.get("ollama") as OllamaDriver;
|
||||
await tools.init();
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
@@ -186,6 +192,28 @@ const server = Bun.serve({
|
||||
}
|
||||
const f = await cdnFile(ns, key, ext);
|
||||
if (!f.exists) return new Response("not found", { status: 404 });
|
||||
if (ns === "avatar") {
|
||||
// Same URL, new bytes after a re-upload: force revalidation (ETag)
|
||||
// so the fresh picture shows immediately without a page refresh.
|
||||
let etag = "";
|
||||
try {
|
||||
const st = statSync(f.path);
|
||||
etag = `"${st.mtimeMs.toString(36)}-${st.size.toString(36)}"`;
|
||||
} catch {
|
||||
// stat failed — serve without ETag
|
||||
}
|
||||
if (etag && req.headers.get("if-none-match") === etag) {
|
||||
return new Response(null, { status: 304 });
|
||||
}
|
||||
return new Response(Bun.file(f.path), {
|
||||
headers: {
|
||||
"Content-Type": cdnMime(ext),
|
||||
"Cache-Control": "no-cache",
|
||||
...(etag ? { ETag: etag } : {}),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(Bun.file(f.path), {
|
||||
headers: {
|
||||
"Content-Type": cdnMime(ext),
|
||||
@@ -271,7 +299,50 @@ const server = Bun.serve({
|
||||
|
||||
// --- key recovery via email (optional — needs SMTP configured) ---
|
||||
if (path === "/api/auth/methods" && req.method === "GET") {
|
||||
return json({ recovery: mailEnabled(), from: mailEnabled() ? config.smtpFrom : undefined });
|
||||
return json({
|
||||
recovery: mailEnabled(),
|
||||
from: mailEnabled() ? config.smtpFrom : undefined,
|
||||
registration: isRegistrationEnabled(),
|
||||
});
|
||||
}
|
||||
|
||||
// --- public self-registration (admin can disable it on the fly) ---
|
||||
if (path === "/api/auth/register" && req.method === "POST") {
|
||||
const ip = clientIp(req, server);
|
||||
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
|
||||
if (!isRegistrationEnabled()) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "registration is currently disabled on this platform" }, 403);
|
||||
}
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "invalid JSON" }, 400);
|
||||
}
|
||||
const username = validUsername(body.username);
|
||||
if (!username) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
|
||||
}
|
||||
if (getUserByUsername(username)) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "username taken" }, 409);
|
||||
}
|
||||
const displayName = body.displayName === undefined ? username : cleanStr(body.displayName, 60);
|
||||
if (displayName === null) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "displayName: 1-60 chars" }, 400);
|
||||
}
|
||||
const email = body.email === undefined ? "" : validEmail(body.email);
|
||||
if (email === null) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "invalid email address" }, 400);
|
||||
}
|
||||
const key = newAccessKey();
|
||||
const created = createUser({ username, displayName, avatarUrl: "", theme: "auto", email, keyHash: hashSecret(key) });
|
||||
clearAttempts(ip);
|
||||
// Show the raw key exactly once.
|
||||
return json({ user: toPublicUser(created), key }, 201);
|
||||
}
|
||||
|
||||
if (path === "/api/auth/recover" && req.method === "POST") {
|
||||
@@ -424,6 +495,128 @@ const server = Bun.serve({
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
// --- admin: platform settings (feature flags) ---
|
||||
if (path === "/api/admin/settings" && req.method === "GET") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
return json({
|
||||
settings: {
|
||||
registrationEnabled: isRegistrationEnabled(),
|
||||
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/admin/settings" && req.method === "PATCH") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
if (body.registrationEnabled !== undefined) {
|
||||
if (typeof body.registrationEnabled !== "boolean") return json({ error: "registrationEnabled must be boolean" }, 400);
|
||||
setSetting("registration_enabled", body.registrationEnabled ? "1" : "0");
|
||||
}
|
||||
if (body.ocrEnabled !== undefined) {
|
||||
if (typeof body.ocrEnabled !== "boolean") return json({ error: "ocrEnabled must be boolean" }, 400);
|
||||
setSetting("tools_ocr_enabled", body.ocrEnabled ? "1" : "0");
|
||||
}
|
||||
return json({
|
||||
settings: {
|
||||
registrationEnabled: isRegistrationEnabled(),
|
||||
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- admin: Ollama model management (pull from the library, delete) ---
|
||||
// Pull streams Ollama's NDJSON progress straight to the admin client.
|
||||
if (path === "/api/admin/ollama/pull" && req.method === "POST") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
if (!/^[a-z0-9._:\/-]{1,100}$/i.test(name)) return json({ error: "invalid model name" }, 400);
|
||||
if (!ollamaDriver.enabled) return json({ error: "ollama driver is disabled" }, 502);
|
||||
const abortUpstream = new AbortController();
|
||||
const onClientAbort = () => abortUpstream.abort();
|
||||
req.signal.addEventListener("abort", onClientAbort);
|
||||
let clientGone = false;
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
const send = (obj: unknown): boolean => {
|
||||
if (clientGone || req.signal.aborted) return false;
|
||||
try {
|
||||
controller.enqueue(enc.encode(JSON.stringify(obj) + "\n"));
|
||||
return true;
|
||||
} catch {
|
||||
clientGone = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${ollamaDriver.host}/api/pull`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: name, stream: true }),
|
||||
signal: abortUpstream.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
send({ error: `ollama /api/pull failed: ${res.status}${errText ? ` — ${errText.slice(0, 200)}` : ""}` });
|
||||
return;
|
||||
}
|
||||
// Passthrough of the NDJSON progress lines (status/digest/completed/total).
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
for await (const chunk of res.body) {
|
||||
if (req.signal.aborted) break;
|
||||
buf += dec.decode(chunk, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (line && !send(JSON.parse(line))) break;
|
||||
}
|
||||
}
|
||||
send({ status: "done" });
|
||||
} catch (e) {
|
||||
if (!req.signal.aborted && !clientGone) send({ error: (e as Error).message });
|
||||
} finally {
|
||||
req.signal.removeEventListener("abort", onClientAbort);
|
||||
abortUpstream.abort();
|
||||
if (!clientGone) {
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
clientGone = true;
|
||||
abortUpstream.abort();
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store", "X-Accel-Buffering": "no" },
|
||||
});
|
||||
}
|
||||
|
||||
// Admin-only model removal (frees disk on the Ollama host).
|
||||
const ollamaDeleteMatch = path.match(/^\/api\/admin\/ollama\/models\/([a-z0-9._:\/-]{1,100})$/i);
|
||||
if (ollamaDeleteMatch && req.method === "DELETE") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
if (!ollamaDriver.enabled) return json({ error: "ollama driver is disabled" }, 502);
|
||||
try {
|
||||
const res = await fetch(`${ollamaDriver.host}/api/delete`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: ollamaDeleteMatch[1] }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) return json({ error: `ollama /api/delete failed: ${res.status}` }, 502);
|
||||
return json({ ok: true });
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
// --- conversations (server-persisted, per account) ---
|
||||
if (path === "/api/conversations" && req.method === "GET") {
|
||||
return json({ conversations: listConversations(user.id) });
|
||||
|
||||
+7
-3
@@ -1,4 +1,5 @@
|
||||
import { config } from "../config.ts";
|
||||
import { isOcrToolEnabled } from "../db.ts";
|
||||
import { driverLog, fmtMs } from "../drivers/log.ts";
|
||||
import type { PlatformTool } from "./types.ts";
|
||||
|
||||
@@ -62,12 +63,15 @@ export class OcrTool implements PlatformTool {
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.available ?? false;
|
||||
return (this.available ?? false) && isOcrToolEnabled();
|
||||
}
|
||||
|
||||
unavailableReason(): string | null {
|
||||
if (this.isAvailable()) return null;
|
||||
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
|
||||
if (!(this.available ?? false)) {
|
||||
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
|
||||
}
|
||||
if (!isOcrToolEnabled()) return "OCR is disabled by the administrator (Admin Center → Features).";
|
||||
return null;
|
||||
}
|
||||
|
||||
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
|
||||
|
||||
Reference in New Issue
Block a user