KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module
This commit is contained in:
+423
@@ -0,0 +1,423 @@
|
||||
import { serveStatic } from "./static.ts";
|
||||
import { assertConfig, config } from "./config.ts";
|
||||
import {
|
||||
clearAttempts,
|
||||
extractBearer,
|
||||
hashSecret,
|
||||
isRateLimited,
|
||||
issueToken,
|
||||
login,
|
||||
newAccessKey,
|
||||
recordAttempt,
|
||||
revokeToken,
|
||||
verifyToken,
|
||||
type PublicUser,
|
||||
} from "./auth.ts";
|
||||
import {
|
||||
addMessage,
|
||||
createConversation,
|
||||
createUser,
|
||||
deleteConversation,
|
||||
deleteShare,
|
||||
deleteUser,
|
||||
deleteUserSessions,
|
||||
ensureShare,
|
||||
getConversation,
|
||||
getMessages,
|
||||
getShareByConv,
|
||||
getShareByPublic,
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
listConversations,
|
||||
listUsers,
|
||||
toPublicUser,
|
||||
touchConversation,
|
||||
updateConversation,
|
||||
updateUser,
|
||||
} from "./db.ts";
|
||||
import type { ChatMessage } from "./drivers/types.ts";
|
||||
import { DriverRegistry } from "./drivers/registry.ts";
|
||||
|
||||
assertConfig();
|
||||
const registry = new DriverRegistry();
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string {
|
||||
try {
|
||||
const info = server.requestIP?.(req);
|
||||
if (info?.address) return info.address;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
}
|
||||
|
||||
function requireAuth(req: Request): PublicUser | null {
|
||||
const token = extractBearer(req);
|
||||
if (!token) return null;
|
||||
return verifyToken(token);
|
||||
}
|
||||
|
||||
function validMessages(v: unknown): v is ChatMessage[] {
|
||||
if (!Array.isArray(v) || v.length === 0 || v.length > 200) return false;
|
||||
return v.every(
|
||||
(m) =>
|
||||
m &&
|
||||
typeof m === "object" &&
|
||||
(m.role === "user" || m.role === "assistant" || m.role === "system") &&
|
||||
typeof m.content === "string" &&
|
||||
m.content.length <= 100_000,
|
||||
);
|
||||
}
|
||||
|
||||
function cleanStr(v: unknown, max: number): string | null {
|
||||
if (typeof v !== "string") return null;
|
||||
const t = v.trim();
|
||||
if (t.length === 0 || t.length > max) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
function validTheme(v: unknown): v is string {
|
||||
return v === "auto" || v === "light" || v === "dark";
|
||||
}
|
||||
|
||||
function validAvatar(v: unknown): string | null {
|
||||
if (v === "" || v === undefined || v === null) return "";
|
||||
if (typeof v !== "string") return null;
|
||||
const t = v.trim();
|
||||
if (t.length > 2048) return null;
|
||||
if (!/^https?:\/\//i.test(t)) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
function validUsername(v: unknown): string | null {
|
||||
if (typeof v !== "string") return null;
|
||||
const t = v.trim().toLowerCase();
|
||||
if (!/^[a-z0-9._-]{2,32}$/.test(t) || t === "admin") 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> };
|
||||
} catch {
|
||||
return { ok: false, body: {} };
|
||||
}
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: config.port,
|
||||
hostname: config.host,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// --- public: shared chat page data (no auth) ---
|
||||
const shareMatch = path.match(/^\/api\/share\/([A-Za-z0-9_-]{6,64})$/);
|
||||
if (shareMatch && req.method === "GET") {
|
||||
const s = getShareByPublic(shareMatch[1]!);
|
||||
if (!s) return json({ error: "share not found" }, 404);
|
||||
return json({
|
||||
title: s.conv.title,
|
||||
topic: s.conv.topic,
|
||||
model: s.conv.model,
|
||||
authorName: s.authorName,
|
||||
sharedAt: s.sharedAt,
|
||||
messages: s.messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
});
|
||||
}
|
||||
|
||||
// --- auth: login (rate limited) ---
|
||||
if (path === "/api/auth/login" && 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);
|
||||
if (!ok || typeof body.username !== "string" || typeof body.key !== "string") {
|
||||
recordAttempt(ip);
|
||||
return json({ error: 'expected { username, key }' }, 400);
|
||||
}
|
||||
const user = login(body.username.trim().toLowerCase(), body.key);
|
||||
if (!user) {
|
||||
recordAttempt(ip);
|
||||
await Bun.sleep(400);
|
||||
return json({ error: "invalid credentials" }, 401);
|
||||
}
|
||||
clearAttempts(ip);
|
||||
const { token, expiresAt } = issueToken(user);
|
||||
return json({ token, expiresAt, user });
|
||||
}
|
||||
|
||||
if (path === "/api/auth/verify" && req.method === "GET") {
|
||||
const user = requireAuth(req);
|
||||
return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout" && req.method === "POST") {
|
||||
const token = extractBearer(req);
|
||||
if (token) revokeToken(token);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
// --- everything below requires auth ---
|
||||
if (path.startsWith("/api/")) {
|
||||
const user = requireAuth(req);
|
||||
if (!user) return json({ error: "unauthorized" }, 401);
|
||||
const admin = user.isAdmin;
|
||||
|
||||
// --- self profile ---
|
||||
if (path === "/api/me" && req.method === "GET") return json({ user });
|
||||
|
||||
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 } = {};
|
||||
if (body.displayName !== undefined) {
|
||||
const d = cleanStr(body.displayName, 60);
|
||||
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
|
||||
patch.displayName = d;
|
||||
}
|
||||
if (body.avatarUrl !== undefined) {
|
||||
const a = validAvatar(body.avatarUrl);
|
||||
if (a === null) return json({ error: "avatarUrl must be an http(s) URL or empty (e.g. catbox.moe link)" }, 400);
|
||||
patch.avatarUrl = a;
|
||||
}
|
||||
if (body.theme !== undefined) {
|
||||
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
|
||||
patch.theme = body.theme;
|
||||
}
|
||||
const updated = updateUser(user.id, patch);
|
||||
if (!updated) return json({ error: "user not found" }, 404);
|
||||
return json({ user: toPublicUser(updated) });
|
||||
}
|
||||
|
||||
// --- admin: user management (No-KYC accounts, keys issued by admin) ---
|
||||
if (path === "/api/admin/users" && req.method === "GET") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
return json({ users: listUsers().map(toPublicUser) });
|
||||
}
|
||||
|
||||
if (path === "/api/admin/users" && 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 username = validUsername(body.username);
|
||||
if (!username) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
|
||||
if (getUserByUsername(username)) return json({ error: "username taken" }, 409);
|
||||
const displayName = body.displayName === undefined ? username : cleanStr(body.displayName, 60);
|
||||
if (displayName === null) return json({ error: "displayName: 1-60 chars" }, 400);
|
||||
const avatarUrl = body.avatarUrl === undefined ? "" : validAvatar(body.avatarUrl);
|
||||
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 key = newAccessKey();
|
||||
const created = createUser({ username, displayName, avatarUrl, theme, keyHash: hashSecret(key) });
|
||||
// Show the raw key exactly once.
|
||||
return json({ user: toPublicUser(created), key }, 201);
|
||||
}
|
||||
|
||||
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate)?$/);
|
||||
if (adminUserMatch) {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
const target = getUserById(adminUserMatch[1]!);
|
||||
if (!target || target.username === "admin") return json({ error: "user not found" }, 404);
|
||||
if (req.method === "DELETE" && !adminUserMatch[2]) {
|
||||
deleteUser(target.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (req.method === "POST" && adminUserMatch[2] === "/regenerate") {
|
||||
const key = newAccessKey();
|
||||
updateUser(target.id, { keyHash: hashSecret(key) });
|
||||
deleteUserSessions(target.id);
|
||||
return json({ key });
|
||||
}
|
||||
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 } = {};
|
||||
if (body.displayName !== undefined) {
|
||||
const d = cleanStr(body.displayName, 60);
|
||||
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
|
||||
patch.displayName = d;
|
||||
}
|
||||
if (body.avatarUrl !== undefined) {
|
||||
const a = validAvatar(body.avatarUrl);
|
||||
if (a === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400);
|
||||
patch.avatarUrl = a;
|
||||
}
|
||||
if (body.theme !== undefined) {
|
||||
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
|
||||
patch.theme = body.theme;
|
||||
}
|
||||
const updated = updateUser(target.id, patch);
|
||||
return json({ user: toPublicUser(updated!) });
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
// --- conversations (server-persisted, per account) ---
|
||||
if (path === "/api/conversations" && req.method === "GET") {
|
||||
return json({ conversations: listConversations(user.id) });
|
||||
}
|
||||
|
||||
if (path === "/api/conversations" && req.method === "POST") {
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
const conv = createConversation(user.id, {
|
||||
title: typeof body.title === "string" ? body.title : undefined,
|
||||
topic: typeof body.topic === "string" ? body.topic : undefined,
|
||||
model: typeof body.model === "string" ? body.model : undefined,
|
||||
});
|
||||
return json({ conversation: conv }, 201);
|
||||
}
|
||||
|
||||
const convMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})(\/(share))?$/);
|
||||
if (convMatch) {
|
||||
const conv = getConversation(convMatch[1]!);
|
||||
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
|
||||
const isShare = convMatch[3] === "share";
|
||||
|
||||
if (isShare) {
|
||||
if (req.method === "POST") {
|
||||
const publicId = ensureShare(conv.id);
|
||||
return json({ publicId, url: `/share/${publicId}` }, 201);
|
||||
}
|
||||
if (req.method === "GET") {
|
||||
const publicId = getShareByConv(conv.id);
|
||||
return publicId ? json({ publicId, url: `/share/${publicId}` }) : json({ shared: false });
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
deleteShare(conv.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
return json({ conversation: conv, messages: getMessages(conv.id) });
|
||||
}
|
||||
if (req.method === "PATCH") {
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
const patch: { title?: string; topic?: string; model?: string } = {};
|
||||
if (body.title !== undefined) {
|
||||
if (typeof body.title !== "string" || body.title.length > 120) return json({ error: "title too long" }, 400);
|
||||
patch.title = body.title.trim();
|
||||
}
|
||||
if (body.topic !== undefined) {
|
||||
if (typeof body.topic !== "string" || body.topic.length > 120) return json({ error: "topic too long" }, 400);
|
||||
patch.topic = body.topic.trim();
|
||||
}
|
||||
if (body.model !== undefined) {
|
||||
if (typeof body.model !== "string" || body.model.length > 160) return json({ error: "model too long" }, 400);
|
||||
patch.model = body.model;
|
||||
}
|
||||
return json({ conversation: updateConversation(conv.id, patch) });
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
deleteConversation(conv.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
if (path === "/api/models" && req.method === "GET") {
|
||||
const models = await registry.listAllModels();
|
||||
return json({ models });
|
||||
}
|
||||
|
||||
if (path === "/api/chat" && req.method === "POST") {
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
if (typeof body.model !== "string" || !validMessages(body.messages)) {
|
||||
return json({ error: 'expected { model: "driver:model", messages: [{role, content}] }' }, 400);
|
||||
}
|
||||
let driver, model: string;
|
||||
try {
|
||||
({ driver, model } = registry.splitId(body.model));
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, 400);
|
||||
}
|
||||
const messages = body.messages as ChatMessage[];
|
||||
// Optional persistence into a server-side conversation (must belong to the user).
|
||||
let conv = null;
|
||||
if (body.conversationId !== undefined) {
|
||||
if (typeof body.conversationId !== "string") return json({ error: "bad conversationId" }, 400);
|
||||
conv = getConversation(body.conversationId);
|
||||
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
|
||||
// Persist the new user message (dedupe against last stored one).
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||||
const stored = getMessages(conv.id);
|
||||
const lastStored = stored[stored.length - 1];
|
||||
if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) {
|
||||
addMessage(conv.id, "user", lastUser.content);
|
||||
if (conv.title === "New chat") {
|
||||
updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
|
||||
}
|
||||
}
|
||||
}
|
||||
const wantStream = body.stream === true || req.headers.get("accept") === "text/event-stream";
|
||||
|
||||
const persistReply = (text: string) => {
|
||||
if (conv && text) {
|
||||
addMessage(conv.id, "assistant", text);
|
||||
touchConversation(conv.id, body.model as string);
|
||||
}
|
||||
};
|
||||
|
||||
if (!wantStream) {
|
||||
try {
|
||||
const text = await driver.chat(messages, { model });
|
||||
persistReply(text);
|
||||
return json({ model: body.model, message: { role: "assistant", content: text } });
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
const send = (event: string, data: string) =>
|
||||
controller.enqueue(enc.encode(`event: ${event}\ndata: ${data}\n\n`));
|
||||
let full = "";
|
||||
try {
|
||||
for await (const token of driver.chatStream(messages, { model })) {
|
||||
full += token;
|
||||
send("token", JSON.stringify({ token }));
|
||||
}
|
||||
persistReply(full);
|
||||
send("done", "{}");
|
||||
} catch (e) {
|
||||
if (full) persistReply(full + "\n\n[interrupted: " + (e as Error).message + "]");
|
||||
send("error", JSON.stringify({ error: (e as Error).message }));
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
// --- static frontend (SPA fallback covers /share/:id) ---
|
||||
return serveStatic(req, url);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`KisAssistant listening on http://${config.host}:${config.port}`);
|
||||
console.log(`Drivers (priority order): ${registry.ordered().map((d) => `${d.name}${d.enabled ? "" : " [disabled]"}`).join(" -> ")}`);
|
||||
void server;
|
||||
Reference in New Issue
Block a user