This commit is contained in:
k
2026-09-21 12:56:11 +02:00
parent a5da592fcf
commit 6cb9cba384
28 changed files with 1290 additions and 63 deletions
+54 -9
View File
@@ -14,27 +14,34 @@ import { config } from "./config.ts";
// the client. Everything else is systematically blacklisted.
export type ImageExt = "jpg" | "png" | "webp";
export type TextExt = "txt";
export type CdnExt = ImageExt | TextExt;
export interface CdnNamespace {
maxBytes: number;
windowMs: number;
maxUploads: number;
/** Allowed content kinds for this namespace. */
kinds: ("image" | "text")[];
}
export const CDN_NAMESPACES: Record<string, CdnNamespace> = {
// Avatar rules (spec): 5 MB max, 5 changes max per 2 h per account.
avatar: { maxBytes: 5 * 1024 * 1024, windowMs: 2 * 3600_000, maxUploads: 5 },
avatar: { maxBytes: 5 * 1024 * 1024, windowMs: 2 * 3600_000, maxUploads: 5, kinds: ["image"] },
// Chat text attachments (spec): 500 KB max plain-text files.
text: { maxBytes: 500 * 1024, windowMs: 3600_000, maxUploads: 30, kinds: ["text"] },
};
export const CDN_EXTS: ImageExt[] = ["jpg", "png", "webp"];
export const CDN_EXTS: CdnExt[] = ["jpg", "png", "webp", "txt"];
const CDN_MIME: Record<ImageExt, string> = {
const CDN_MIME: Record<CdnExt, string> = {
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp",
txt: "text/plain; charset=utf-8",
};
export function cdnMime(ext: ImageExt): string {
export function cdnMime(ext: CdnExt): string {
return CDN_MIME[ext];
}
@@ -46,7 +53,7 @@ export function validKey(key: string): boolean {
return /^[A-Za-z0-9_-]{1,64}$/.test(key);
}
export function validExt(ext: string): ext is ImageExt {
export function validExt(ext: string): ext is CdnExt {
return (CDN_EXTS as string[]).includes(ext);
}
@@ -54,11 +61,11 @@ function cdnDir(): string {
return config.cdnDir;
}
export function cdnPath(ns: string, key: string, ext: ImageExt): string {
export function cdnPath(ns: string, key: string, ext: CdnExt): string {
return join(cdnDir(), ns, `${key}.${ext}`);
}
export async function cdnFile(ns: string, key: string, ext: ImageExt): Promise<{ path: string; exists: boolean }> {
export async function cdnFile(ns: string, key: string, ext: CdnExt): Promise<{ path: string; exists: boolean }> {
const path = cdnPath(ns, key, ext);
return { path, exists: await Bun.file(path).exists() };
}
@@ -89,7 +96,45 @@ export function detectImageType(buf: Uint8Array): ImageExt | null {
return null;
}
/** Remove every stored variant for a key (used before replacing an avatar). */
/**
* Detect plain-text content: strict UTF-8 decode of a sample plus no NUL
* bytes anywhere (binary files are rejected, not just mislabeled ones).
*/
export function isTextContent(buf: Uint8Array): boolean {
if (buf.length === 0) return false;
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0) return false;
}
try {
new TextDecoder("utf-8", { fatal: true }).decode(buf.subarray(0, Math.min(buf.length, 65536)));
} catch {
return false;
}
return true;
}
/**
* Detect the stored extension for a namespace from content (never the client).
* Returns null when the content is not allowed in this namespace.
*/
export function detectCdnType(ns: string, buf: Uint8Array): CdnExt | null {
const kinds = CDN_NAMESPACES[ns]?.kinds ?? [];
if (kinds.includes("image")) {
const img = detectImageType(buf);
if (img) return img;
}
if (kinds.includes("text") && isTextContent(buf)) return "txt";
return null;
}
/** Human description of what a namespace accepts (for 415 errors). */
export function namespaceAccepts(ns: string): string {
const kinds = CDN_NAMESPACES[ns]?.kinds ?? [];
const parts: string[] = [];
if (kinds.includes("image")) parts.push("jpg/png/webp images");
if (kinds.includes("text")) parts.push("plain-text files");
return parts.join(" and ") || "nothing";
}
export async function removeCdnVariants(ns: string, key: string): Promise<void> {
const { unlink } = await import("node:fs/promises");
for (const ext of CDN_EXTS) {
@@ -101,7 +146,7 @@ export async function removeCdnVariants(ns: string, key: string): Promise<void>
}
}
export async function writeCdnFile(ns: string, key: string, ext: ImageExt, data: Uint8Array): Promise<string> {
export async function writeCdnFile(ns: string, key: string, ext: CdnExt, data: Uint8Array): Promise<string> {
await mkdir(join(cdnDir(), ns), { recursive: true });
await removeCdnVariants(ns, key);
await Bun.write(cdnPath(ns, key, ext), data);
+14
View File
@@ -54,6 +54,20 @@ export const config = {
// Dev default is a folder next to the codebase; NixOS module points it
// under the service state dir.
cdnDir: env("CDN_DIR", "./cdn"),
// OCR tool (tesseract binary).
ocrLang: env("OCR_LANG", "eng"),
ocrMaxChars: envInt("OCR_MAX_CHARS", 100_000),
// Key recovery via email (optional — empty SMTP_HOST disables it).
smtpHost: env("SMTP_HOST", ""),
smtpPort: envInt("SMTP_PORT", 587),
smtpSecure: envBool("SMTP_SECURE", false), // true = implicit TLS (port 465)
smtpUser: env("SMTP_USER", ""),
smtpPass: env("SMTP_PASS", ""),
smtpFrom: env("SMTP_FROM", "KisAssistant <[email protected]>"),
appUrl: env("APP_URL", "http://localhost:3000").replace(/\/$/, ""),
resetTtlMs: envInt("RESET_TTL_MIN", 60) * 60_000,
};
export function assertConfig(): void {
+62 -4
View File
@@ -12,6 +12,7 @@ export interface UserRow {
display_name: string;
avatar_url: string;
theme: string;
email: string;
key_hash: string;
created_at: number;
}
@@ -47,6 +48,7 @@ export interface PublicUser {
displayName: string;
avatarUrl: string;
theme: string;
email: string;
createdAt: number;
isAdmin: boolean;
}
@@ -100,7 +102,18 @@ export function getDb(): Database {
public_id TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_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 }[];
if (!cols.some((c) => c.name === "email")) {
db.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
}
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
if (!admin) {
@@ -118,6 +131,7 @@ export function toPublicUser(u: UserRow): PublicUser {
displayName: u.display_name,
avatarUrl: u.avatar_url,
theme: u.theme,
email: u.email ?? "",
createdAt: u.created_at,
isAdmin: u.username === "admin",
};
@@ -137,7 +151,7 @@ export function listUsers(): UserRow[] {
return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[];
}
export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; keyHash: string }): UserRow {
export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): UserRow {
const d = getDb();
const row: UserRow = {
id: randomUUID(),
@@ -145,22 +159,24 @@ export function createUser(opts: { username: string; displayName: string; avatar
display_name: opts.displayName,
avatar_url: opts.avatarUrl,
theme: opts.theme,
email: opts.email,
key_hash: opts.keyHash,
created_at: Date.now(),
};
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.key_hash, row.created_at,
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at,
);
return row;
}
export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; keyHash?: string }): UserRow | null {
export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null {
const d = getDb();
const sets: string[] = [];
const vals: (string | number)[] = [];
if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); }
if (patch.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); }
if (patch.theme !== undefined) { sets.push("theme = ?"); vals.push(patch.theme); }
if (patch.email !== undefined) { sets.push("email = ?"); vals.push(patch.email); }
if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
if (sets.length > 0) {
d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
@@ -181,6 +197,7 @@ export function deleteUser(id: string): void {
});
for (const c of convs) txn(c.id);
d.query("DELETE FROM sessions WHERE user_id = ?").run(id);
d.query("DELETE FROM resets WHERE user_id = ?").run(id);
d.query("DELETE FROM users WHERE id = ?").run(id);
}
@@ -274,6 +291,47 @@ export function getMessages(convId: string): MessageRow[] {
return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[];
}
// --- password-recovery resets (one-time, hashed, expiring) ---
export interface ResetRow {
token_hash: string;
user_id: string;
created_at: number;
expires_at: number;
}
export function createReset(tokenHash: string, userId: string, expiresAt: number): void {
const d = getDb();
// One active reset per account: replace any previous one.
d.query("DELETE FROM resets WHERE user_id = ?").run(userId);
d.query("INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
tokenHash, userId, Date.now(), expiresAt,
);
}
export function peekReset(tokenHash: string): ResetRow | null {
const d = getDb();
const r = d.query("SELECT * FROM resets WHERE token_hash = ?").get(tokenHash) as ResetRow | null;
if (!r) return null;
if (r.expires_at <= Date.now()) {
d.query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
return null;
}
return r;
}
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
export function consumeReset(tokenHash: string): ResetRow | null {
const r = peekReset(tokenHash);
if (!r) return null;
getDb().query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
return r;
}
export function deleteUserResets(userId: string): void {
getDb().query("DELETE FROM resets WHERE user_id = ?").run(userId);
}
// --- public shares ---
export function ensureShare(convId: string): string {
+125 -8
View File
@@ -15,7 +15,9 @@ import {
} from "./auth.ts";
import {
addMessage,
consumeReset,
createConversation,
createReset,
createUser,
deleteConversation,
deleteShare,
@@ -30,6 +32,7 @@ import {
getUserByUsername,
listConversations,
listUsers,
peekReset,
toPublicUser,
touchConversation,
updateConversation,
@@ -37,11 +40,15 @@ import {
} from "./db.ts";
import type { ChatMessage } from "./drivers/types.ts";
import { DriverRegistry } from "./drivers/registry.ts";
import { createHash, randomBytes } from "node:crypto";
import { mailEnabled, sendRecoveryEmail } from "./mail.ts";
import {
CDN_NAMESPACES,
cdnFile,
cdnMime,
detectCdnType,
detectImageType,
namespaceAccepts,
recordUpload,
uploadAllowed,
validExt,
@@ -49,9 +56,12 @@ import {
validNamespace,
writeCdnFile,
} from "./cdn.ts";
import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts";
import { tools } from "./tools/registry.ts";
assertConfig();
const registry = new DriverRegistry();
await tools.init();
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
@@ -117,6 +127,16 @@ function validUsername(v: unknown): string | null {
return t;
}
/** Empty string (unset) or a plausible email address. */
function validEmail(v: unknown): string | null {
if (v === "" || v === undefined || v === null) return "";
if (typeof v !== "string") return null;
const t = v.trim();
if (t.length > 320) return null;
if (!/^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/.test(t)) return null;
return t;
}
async function readJson(req: Request): Promise<{ ok: boolean; body: Record<string, unknown> }> {
try {
return { ok: true, body: (await req.json()) as Record<string, unknown> };
@@ -185,9 +205,11 @@ const server = Bun.serve({
const [, ns, key] = cdnPut as [string, string, string];
if (!validNamespace(ns) || !validKey(key)) return json({ error: "unknown namespace or key" }, 404);
const rules = CDN_NAMESPACES[ns]!;
// Avatar ownership: your own account id, unless admin.
if (ns === "avatar" && key !== user.id && !user.isAdmin) {
return json({ error: "forbidden" }, 403);
// Ownership: avatar keys are exactly your account id; text keys must
// live under your account id prefix. Admin may write for anyone.
if (!user.isAdmin) {
if (ns === "avatar" && key !== user.id) return json({ error: "forbidden" }, 403);
if (ns === "text" && !key.startsWith(user.id)) return json({ error: "forbidden" }, 403);
}
const declared = Number(req.headers.get("content-length") || "0");
const maxMb = (rules.maxBytes / (1024 * 1024)).toFixed(0);
@@ -200,9 +222,9 @@ const server = Bun.serve({
}
if (buf.length < 16) return json({ error: "empty file" }, 400);
if (buf.length > rules.maxBytes) return json({ error: `file too large (max ${maxMb}MB)` }, 413);
const ext = detectImageType(buf);
const ext = detectCdnType(ns, buf);
if (!ext) {
return json({ error: "unsupported file type: only jpg/png/webp images are accepted (verified by content, not extension)" }, 415);
return json({ error: `unsupported file type: this namespace only accepts ${namespaceAccepts(ns)} (verified by content, not extension)` }, 415);
}
const rl = uploadAllowed(user.id, ns);
if (!rl.ok) {
@@ -247,6 +269,53 @@ const server = Bun.serve({
return json({ ok: true });
}
// --- 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 });
}
if (path === "/api/auth/recover" && req.method === "POST") {
const ip = clientIp(req, server);
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
const { ok, body } = await readJson(req);
// Never enumerate accounts: always answer ok.
if (ok && typeof body.username === "string") {
const name = body.username.trim().toLowerCase();
const target = name === "admin" ? null : getUserByUsername(name);
if (target && target.email && mailEnabled()) {
const token = "rt_" + randomBytes(24).toString("base64url");
createReset(createHash("sha256").update(token).digest("hex"), target.id, Date.now() + config.resetTtlMs);
sendRecoveryEmail(target.email, target.username, `${config.appUrl}/reset/${token}`).catch(() => {});
clearAttempts(ip);
return json({ ok: true });
}
}
recordAttempt(ip);
await Bun.sleep(400);
return json({ ok: true });
}
const resetMatch = path.match(/^\/api\/auth\/reset\/([A-Za-z0-9_-]{6,80})$/);
if (resetMatch) {
const tokenHash = createHash("sha256").update(resetMatch[1]!).digest("hex");
if (req.method === "GET") {
const r = peekReset(tokenHash);
const u = r ? getUserById(r.user_id) : null;
if (!r || !u) return json({ error: "invalid or expired link" }, 404);
return json({ ok: true, username: u.username });
}
if (req.method === "POST") {
const r = consumeReset(tokenHash);
const u = r ? getUserById(r.user_id) : null;
if (!r || !u || u.username === "admin") return json({ error: "invalid or expired link" }, 404);
// Recovery = instant key rotation; the new key is shown exactly once.
const key = newAccessKey();
updateUser(u.id, { keyHash: hashSecret(key) });
deleteUserSessions(u.id);
return json({ key });
}
}
// --- everything below requires auth ---
if (path.startsWith("/api/")) {
const user = requireAuth(req);
@@ -259,7 +328,7 @@ const server = Bun.serve({
if (path === "/api/me" && req.method === "PATCH") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { displayName?: string; avatarUrl?: string; theme?: string } = {};
const patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
if (body.displayName !== undefined) {
const d = cleanStr(body.displayName, 60);
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
@@ -274,6 +343,11 @@ const server = Bun.serve({
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
patch.theme = body.theme;
}
if (body.email !== undefined) {
const e = validEmail(body.email);
if (e === null) return json({ error: "invalid email address" }, 400);
patch.email = e;
}
const updated = updateUser(user.id, patch);
if (!updated) return json({ error: "user not found" }, 404);
return json({ user: toPublicUser(updated) });
@@ -298,8 +372,10 @@ const server = Bun.serve({
if (avatarUrl === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400);
const theme = body.theme === undefined ? "auto" : body.theme;
if (!validTheme(theme)) return json({ error: "theme must be auto|light|dark" }, 400);
const email = body.email === undefined ? "" : validEmail(body.email);
if (email === null) return json({ error: "invalid email address" }, 400);
const key = newAccessKey();
const created = createUser({ username, displayName, avatarUrl, theme, keyHash: hashSecret(key) });
const created = createUser({ username, displayName, avatarUrl, theme, email, keyHash: hashSecret(key) });
// Show the raw key exactly once.
return json({ user: toPublicUser(created), key }, 201);
}
@@ -322,7 +398,7 @@ const server = Bun.serve({
if (req.method === "PATCH" && !adminUserMatch[2]) {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { displayName?: string; avatarUrl?: string; theme?: string } = {};
const patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
if (body.displayName !== undefined) {
const d = cleanStr(body.displayName, 60);
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
@@ -337,6 +413,11 @@ const server = Bun.serve({
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
patch.theme = body.theme;
}
if (body.email !== undefined) {
const e = validEmail(body.email);
if (e === null) return json({ error: "invalid email address" }, 400);
patch.email = e;
}
const updated = updateUser(target.id, patch);
return json({ user: toPublicUser(updated!) });
}
@@ -414,6 +495,42 @@ const server = Bun.serve({
return json({ models });
}
// --- platform tools (uploads always go through tools) ---
if (path === "/api/tools" && req.method === "GET") {
return json({ tools: tools.list() });
}
if (path === "/api/tools/ocr" && req.method === "POST") {
const ocr = tools.ocr;
if (!ocr.isAvailable()) return json({ error: ocr.unavailableReason() ?? "ocr unavailable" }, 501);
const declared = Number(req.headers.get("content-length") || "0");
if (declared > OCR_MAX_BYTES) return json({ error: "image too large (max 5MB)" }, 413);
let buf: Uint8Array;
try {
buf = new Uint8Array(await req.arrayBuffer());
} catch {
return json({ error: "unreadable body" }, 400);
}
if (buf.length < 16) return json({ error: "empty file" }, 400);
if (buf.length > OCR_MAX_BYTES) return json({ error: "image too large (max 5MB)" }, 413);
if (!detectImageType(buf)) {
return json({ error: "only jpg/png/webp images are accepted (verified by content, not extension)" }, 415);
}
const rl = ocrAllowed(user.id);
if (!rl.ok) {
return json({ error: "ocr rate limited: 20 jobs per hour", retryAfterSec: rl.retryAfterSec }, 429);
}
recordOcrJob(user.id);
try {
const { text, truncated } = await ocr.transcribe(buf);
return json({ text, truncated, chars: text.length });
} catch (e) {
const msg = (e as Error).message;
if (/not available|failed to start/i.test(msg)) return json({ error: msg }, 501);
return json({ error: msg }, 502);
}
}
if (path === "/api/chat" && req.method === "POST") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
+68
View File
@@ -0,0 +1,68 @@
import type { Transporter } from "nodemailer";
import { config } from "./config.ts";
let transporter: Transporter | null = null;
let warned = false;
export function mailEnabled(): boolean {
return config.smtpHost.length > 0;
}
// Lazy import: the backend boots (and serves) fine without node_modules
// installed; SMTP is only needed when actually sending.
async function loadMailer(): Promise<typeof import("nodemailer") | null> {
try {
return await import("nodemailer");
} catch {
if (!warned) {
warned = true;
console.error("[mail] nodemailer not installed — run `bun install` for email support");
}
return null;
}
}
async function getTransporter(): Promise<Transporter | null> {
if (!mailEnabled()) return null;
if (!transporter) {
const mailer = await loadMailer();
if (!mailer) return null;
transporter = mailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
});
}
return transporter;
}
/** Send a key-recovery email with a one-time reset link. Fire-and-forget safe. */
export async function sendRecoveryEmail(to: string, username: string, resetUrl: string): Promise<boolean> {
const t = await getTransporter();
if (!t) {
if (!warned) {
warned = true;
console.error("[mail] SMTP_HOST not set — recovery emails disabled");
}
return false;
}
try {
await t.sendMail({
from: config.smtpFrom,
to,
subject: "KisAssistant — recover your access key",
text:
`Hello ${username},\n\n` +
`Someone requested a new access key for your KisAssistant account.\n` +
`Open this link within 60 minutes to get a fresh key (your old one stops working):\n\n` +
`${resetUrl}\n\n` +
`If you did not request this, just ignore this email.\n`,
});
console.log(`[mail] recovery email sent to ${username}`);
return true;
} catch (e) {
console.error("[mail] send failed:", (e as Error).message);
return false;
}
}
+117
View File
@@ -0,0 +1,117 @@
import { config } from "../config.ts";
import { driverLog, fmtMs } from "../drivers/log.ts";
import type { PlatformTool } from "./types.ts";
/**
* OCR tool: transcribes JPG/PNG/WEBP images to TXT on the backend
* (tesseract binary) so the model only ever receives text.
* The frontend shows the transcription for user review before sending.
*/
const OCR_MAX_BYTES = 5 * 1024 * 1024;
// CPU protection: max OCR jobs per account per hour.
const OCR_WINDOW_MS = 3600_000;
const OCR_MAX_JOBS = 20;
const ocrJobs = new Map<string, number[]>();
export function ocrAllowed(userId: string): { ok: boolean; retryAfterSec: number } {
const now = Date.now();
const list = (ocrJobs.get(userId) ?? []).filter((t) => t > now - OCR_WINDOW_MS);
ocrJobs.set(userId, list);
if (list.length >= OCR_MAX_JOBS) {
return { ok: false, retryAfterSec: Math.max(1, Math.ceil((list[0]! + OCR_WINDOW_MS - now) / 1000)) };
}
return { ok: true, retryAfterSec: 0 };
}
export function recordOcrJob(userId: string): void {
const list = ocrJobs.get(userId) ?? [];
list.push(Date.now());
ocrJobs.set(userId, list);
}
function tessBin(): string {
return process.env["TESSERACT_BIN"] || "tesseract";
}
let availableCache: boolean | null = null;
async function checkBinary(): Promise<boolean> {
try {
const proc = Bun.spawn([tessBin(), "--version"], { stdout: "pipe", stderr: "pipe" });
const code = await proc.exited;
return code === 0;
} catch {
return false;
}
}
export class OcrTool implements PlatformTool {
readonly name = "ocr";
readonly description = "Transcribe image (jpg/png/webp) to text on the server (tesseract) for user review before sending to the model.";
private available: boolean | null = null;
async init(): Promise<void> {
if (availableCache === null) {
availableCache = await checkBinary();
driverLog("tools", `ocr engine ${tessBin()}: ${availableCache ? "available" : "MISSING — image transcription disabled"}`);
}
this.available = availableCache;
}
isAvailable(): boolean {
return this.available ?? false;
}
unavailableReason(): string | null {
if (this.isAvailable()) return null;
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
}
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
if (!this.isAvailable()) throw new Error(this.unavailableReason() ?? "ocr unavailable");
const t0 = Date.now();
const maxChars = config.ocrMaxChars;
let proc;
try {
proc = Bun.spawn([tessBin(), "stdin", "stdout", "-l", config.ocrLang, "--psm", "3"], {
stdin: image,
stdout: "pipe",
stderr: "pipe",
});
} catch (e) {
throw new Error(`ocr engine failed to start: ${(e as Error).message}`);
}
const timeout = setTimeout(() => {
try {
proc.kill();
} catch {
// already exited
}
}, 120_000);
try {
const [code, out, errText] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
if (code !== 0) {
throw new Error(`ocr failed (exit ${code}): ${errText.trim().slice(0, 300)}`);
}
let text = out.replace(/\f/g, "").trim();
let truncated = false;
if (text.length > maxChars) {
text = text.slice(0, maxChars);
truncated = true;
}
driverLog("tools", `ocr done: ${text.length} chars${truncated ? " (truncated)" : ""} in ${fmtMs(Date.now() - t0)}`);
return { text, truncated };
} finally {
clearTimeout(timeout);
}
}
}
export { OCR_MAX_BYTES };
+23
View File
@@ -0,0 +1,23 @@
import { OcrTool } from "./ocr.ts";
import type { PlatformTool } from "./types.ts";
/** Platform tools registry (upload flows always go through tools). */
class ToolRegistry {
readonly ocr = new OcrTool();
private tools: PlatformTool[] = [this.ocr];
async init(): Promise<void> {
await this.ocr.init();
}
list(): { name: string; description: string; available: boolean; reason: string | null }[] {
return this.tools.map((t) => ({
name: t.name,
description: t.description,
available: t.isAvailable(),
reason: t.unavailableReason(),
}));
}
}
export const tools = new ToolRegistry();
+8
View File
@@ -0,0 +1,8 @@
/** Platform tool: a server-side capability the frontend invokes explicitly
* (uploads always go through tools — never raw to the model). */
export interface PlatformTool {
readonly name: string;
readonly description: string;
isAvailable(): boolean;
unavailableReason(): string | null;
}