update
This commit is contained in:
+54
-9
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user