.
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDb } from "./db.ts";
|
||||
|
||||
/**
|
||||
* Access-request wishlist (registration approval queue) + minimal ticket
|
||||
* system. Tables are created idempotently here so db.ts stays untouched.
|
||||
*
|
||||
* Flow: visitor reserves username+email with a motivation message ->
|
||||
* ticket page at /review/<request-id> (UUID, unguessable — same pattern as
|
||||
* public share links) -> admin triages in the AdminCenter (reviewing /
|
||||
* accepted / refused, each with a reason) -> both sides exchange messages;
|
||||
* every admin event emails the requester (notification-only).
|
||||
*/
|
||||
|
||||
export type AccessStatus = "pending" | "reviewing" | "accepted" | "refused";
|
||||
|
||||
export interface AccessRequest {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
message: string;
|
||||
status: AccessStatus;
|
||||
reason: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface AccessMessage {
|
||||
id: number;
|
||||
request_id: string;
|
||||
author: "user" | "admin";
|
||||
body: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export function initAccessTables(): void {
|
||||
const d = getDb();
|
||||
d.query(`CREATE TABLE IF NOT EXISTS access_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`).run();
|
||||
d.query(`CREATE TABLE IF NOT EXISTS access_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
|
||||
author TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)`).run();
|
||||
d.query(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`).run();
|
||||
d.query(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`).run();
|
||||
}
|
||||
|
||||
export function createAccessRequest(opts: {
|
||||
username: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}): AccessRequest {
|
||||
const now = Date.now();
|
||||
const row: AccessRequest = {
|
||||
id: randomUUID(),
|
||||
username: opts.username,
|
||||
email: opts.email,
|
||||
message: opts.message,
|
||||
status: "pending",
|
||||
reason: "",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
getDb().query(
|
||||
"INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function getAccessRequest(id: string): AccessRequest | null {
|
||||
return getDb().query("SELECT * FROM access_requests WHERE id = ?").get(id) as AccessRequest | null;
|
||||
}
|
||||
|
||||
/** Open (non-terminal) request already reserving this username or email. */
|
||||
export function findOpenAccessRequest(username: string, email: string): AccessRequest | null {
|
||||
return getDb().query(
|
||||
"SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1",
|
||||
).get(username, email) as AccessRequest | null;
|
||||
}
|
||||
|
||||
export function listAccessRequests(): (AccessRequest & { message_count: number })[] {
|
||||
return getDb().query(
|
||||
`SELECT r.*, (SELECT COUNT(*) FROM access_messages m WHERE m.request_id = r.id) AS message_count
|
||||
FROM access_requests r ORDER BY r.updated_at DESC`,
|
||||
).all() as (AccessRequest & { message_count: number })[];
|
||||
}
|
||||
|
||||
export function setAccessStatus(id: string, status: AccessStatus, reason: string): AccessRequest | null {
|
||||
getDb().query("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?").run(status, reason, Date.now(), id);
|
||||
return getAccessRequest(id);
|
||||
}
|
||||
|
||||
export function listAccessMessages(requestId: string): AccessMessage[] {
|
||||
return getDb().query("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC").all(requestId) as AccessMessage[];
|
||||
}
|
||||
|
||||
export function addAccessMessage(requestId: string, author: "user" | "admin", body: string): AccessMessage {
|
||||
const now = Date.now();
|
||||
getDb().query("INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)").run(requestId, author, body, now);
|
||||
getDb().query("UPDATE access_requests SET updated_at = ? WHERE id = ?").run(now, requestId);
|
||||
const id = (getDb().query("SELECT last_insert_rowid() AS id").get() as { id: number }).id;
|
||||
return { id, request_id: requestId, author, body, created_at: now };
|
||||
}
|
||||
+153
-2
@@ -43,12 +43,23 @@ import {
|
||||
updateUser,
|
||||
} from "./db.ts";
|
||||
import type { ChatMessage } from "./drivers/types.ts";
|
||||
import {
|
||||
addAccessMessage,
|
||||
createAccessRequest,
|
||||
findOpenAccessRequest,
|
||||
getAccessRequest,
|
||||
initAccessTables,
|
||||
listAccessMessages,
|
||||
listAccessRequests,
|
||||
setAccessStatus,
|
||||
type AccessStatus,
|
||||
} from "./access.ts";
|
||||
import type { ConversationRow } from "./db.ts";
|
||||
import type { OllamaDriver } from "./drivers/ollama.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 { mailEnabled, sendAccessMessageEmail, sendAccessReceivedEmail, sendAccessStatusEmail, sendRecoveryEmail } from "./mail.ts";
|
||||
import {
|
||||
CDN_NAMESPACES,
|
||||
cdnFile,
|
||||
@@ -71,6 +82,7 @@ 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();
|
||||
initAccessTables();
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
@@ -390,6 +402,66 @@ const server = Bun.serve({
|
||||
}
|
||||
}
|
||||
|
||||
// --- access-request wishlist (public ticket system, email-notified) ---
|
||||
// Reserve a username + email with a motivation; triaged in the AdminCenter.
|
||||
if (path === "/api/access/request" && 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) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "invalid JSON" }, 400);
|
||||
}
|
||||
const username = validUsername(body.username);
|
||||
const email = validEmail(body.email);
|
||||
const message = cleanStr(body.message, 2000);
|
||||
if (!username || !email || !message || message.length < 10) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "username (2-32 chars), valid email and a motivation (10+ chars) are required" }, 400);
|
||||
}
|
||||
if (getUserByUsername(username)) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "username taken" }, 409);
|
||||
}
|
||||
if (findOpenAccessRequest(username, email)) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "a request for this username or email is already open" }, 409);
|
||||
}
|
||||
const created = createAccessRequest({ username, email, message });
|
||||
addAccessMessage(created.id, "user", message);
|
||||
clearAttempts(ip);
|
||||
// Ticket page: /review/<uuid> (unguessable, same pattern as share links).
|
||||
const reviewUrl = `${config.appUrl}/review/${created.id}`;
|
||||
sendAccessReceivedEmail(email, username, reviewUrl).catch(() => {});
|
||||
return json({ request: created, reviewUrl }, 201);
|
||||
}
|
||||
|
||||
const ticketMatch = path.match(/^\/api\/access\/ticket\/([0-9a-f-]{36})(\/message)?$/);
|
||||
if (ticketMatch) {
|
||||
const r = getAccessRequest(ticketMatch[1]!);
|
||||
if (!r) return json({ error: "invalid ticket link" }, 404);
|
||||
if (req.method === "GET" && !ticketMatch[2]) {
|
||||
return json({ request: r, messages: listAccessMessages(r.id) });
|
||||
}
|
||||
if (req.method === "POST" && ticketMatch[2] === "/message") {
|
||||
const ip = clientIp(req, server);
|
||||
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
|
||||
if (r.status === "accepted" || r.status === "refused") {
|
||||
return json({ error: "ticket closed" }, 403);
|
||||
}
|
||||
const { ok, body } = await readJson(req);
|
||||
const text = ok ? cleanStr(body.body, 2000) : null;
|
||||
if (!text) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "message: 1-2000 chars" }, 400);
|
||||
}
|
||||
const msg = addAccessMessage(r.id, "user", text);
|
||||
clearAttempts(ip);
|
||||
return json({ message: msg }, 201);
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
// --- everything below requires auth ---
|
||||
if (path.startsWith("/api/")) {
|
||||
const user = requireAuth(req);
|
||||
@@ -430,7 +502,35 @@ const server = Bun.serve({
|
||||
// --- 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) });
|
||||
// Listing with search / sort / filter / pagination for the Admin Center.
|
||||
const q = (url.searchParams.get("q") || "").trim().toLowerCase();
|
||||
const sort = url.searchParams.get("sort") || "newest";
|
||||
const filter = url.searchParams.get("filter") || "all"; // all | with-email | no-email
|
||||
const per = Math.min(Math.max(Number(url.searchParams.get("per")) || 10, 1), 50);
|
||||
const page = Math.max(Number(url.searchParams.get("page")) || 1, 1);
|
||||
let users = listUsers();
|
||||
if (q) {
|
||||
users = users.filter(
|
||||
(u) =>
|
||||
u.username.includes(q) ||
|
||||
u.display_name.toLowerCase().includes(q) ||
|
||||
(u.email || "").toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
if (filter === "with-email") users = users.filter((u) => u.email);
|
||||
else if (filter === "no-email") users = users.filter((u) => !u.email);
|
||||
const sorts: Record<string, (a: (typeof users)[number], b: (typeof users)[number]) => number> = {
|
||||
newest: (a, b) => b.created_at - a.created_at,
|
||||
oldest: (a, b) => a.created_at - b.created_at,
|
||||
az: (a, b) => a.username.localeCompare(b.username),
|
||||
za: (a, b) => b.username.localeCompare(a.username),
|
||||
};
|
||||
users.sort(sorts[sort] ?? sorts.newest);
|
||||
const total = users.length;
|
||||
const pages = Math.max(1, Math.ceil(total / per));
|
||||
const safePage = Math.min(page, pages);
|
||||
const slice = users.slice((safePage - 1) * per, safePage * per);
|
||||
return json({ users: slice.map(toPublicUser), total, page: safePage, perPage: per, pages });
|
||||
}
|
||||
|
||||
if (path === "/api/admin/users" && req.method === "POST") {
|
||||
@@ -620,6 +720,57 @@ const server = Bun.serve({
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin: access-request wishlist triage ---
|
||||
if (path === "/api/admin/access" && req.method === "GET") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
return json({ requests: listAccessRequests() });
|
||||
}
|
||||
|
||||
const accessMatch = path.match(/^\/api\/admin\/access\/([0-9a-f-]{36})(\/message)?$/);
|
||||
if (accessMatch) {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
const target = getAccessRequest(accessMatch[1]!);
|
||||
if (!target) return json({ error: "request not found" }, 404);
|
||||
const reviewUrl = `${config.appUrl}/review/${target.id}`;
|
||||
if (req.method === "GET" && !accessMatch[2]) {
|
||||
return json({ request: target, messages: listAccessMessages(target.id) });
|
||||
}
|
||||
if (req.method === "POST" && accessMatch[2] === "/message") {
|
||||
const { ok, body } = await readJson(req);
|
||||
const text = ok ? cleanStr(body.body, 2000) : null;
|
||||
if (!text) return json({ error: "message: 1-2000 chars" }, 400);
|
||||
const msg = addAccessMessage(target.id, "admin", text);
|
||||
// Every admin reply is forwarded to the requester (notification-only).
|
||||
sendAccessMessageEmail(target.email, target.username, true, text, reviewUrl).catch(() => {});
|
||||
return json({ message: msg });
|
||||
}
|
||||
if (req.method === "PATCH" && !accessMatch[2]) {
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
const status = body.status as AccessStatus | undefined;
|
||||
if (status !== "pending" && status !== "reviewing" && status !== "accepted" && status !== "refused") {
|
||||
return json({ error: "status must be pending|reviewing|accepted|refused" }, 400);
|
||||
}
|
||||
const reason = body.reason === undefined ? "" : cleanStr(body.reason, 500);
|
||||
if (reason === null) return json({ error: "reason: max 500 chars" }, 400);
|
||||
if ((target.status === "accepted" || target.status === "refused") && status !== target.status) {
|
||||
return json({ error: "ticket closed" }, 409);
|
||||
}
|
||||
let key: string | undefined;
|
||||
if (status === "accepted" && target.status !== "accepted") {
|
||||
if (getUserByUsername(target.username)) return json({ error: "username taken" }, 409);
|
||||
key = newAccessKey();
|
||||
createUser({ username: target.username, displayName: target.username, avatarUrl: "", theme: "auto", email: target.email, keyHash: hashSecret(key) });
|
||||
}
|
||||
const updated = setAccessStatus(target.id, status, reason);
|
||||
if (status !== target.status && status !== "pending") {
|
||||
sendAccessStatusEmail(target.email, target.username, status, reason, reviewUrl, key).catch(() => {});
|
||||
}
|
||||
return json({ request: updated, ...(key ? { key } : {}) });
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
// --- conversations (server-persisted, per account) ---
|
||||
if (path === "/api/conversations" && req.method === "GET") {
|
||||
return json({ conversations: listConversations(user.id) });
|
||||
|
||||
+130
@@ -37,6 +37,136 @@ async function getTransporter(): Promise<Transporter | null> {
|
||||
return transporter;
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared branded layout for ticket emails. Every mail states the same rule:
|
||||
* email is notification-only — real replies happen on the /review page.
|
||||
*/
|
||||
function ticketLayout(title: string, bodyHtml: string, reviewUrl: string): { html: string; text: string } {
|
||||
const text = bodyHtml
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n\n")
|
||||
.replace(/<[^>]+>/g, "");
|
||||
const html =
|
||||
`<!doctype html><html><body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">` +
|
||||
`<div style="max-width:560px;margin:0 auto;padding:24px 16px;">` +
|
||||
`<div style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e7e5e4;">` +
|
||||
`<div style="background:#059669;padding:20px 24px;color:#ffffff;">` +
|
||||
`<div style="font-size:20px;font-weight:700;">KisAssistant</div>` +
|
||||
`<div style="font-size:13px;opacity:.85;">${escHtml(title)}</div>` +
|
||||
`</div>` +
|
||||
`<div style="padding:24px;color:#1c1917;font-size:14px;line-height:1.6;">${bodyHtml}</div>` +
|
||||
`<div style="padding:16px 24px;background:#fafaf9;border-top:1px solid #e7e5e4;font-size:12px;color:#78716c;">` +
|
||||
`This email is notification-only — please do not reply to it.<br>` +
|
||||
`Respond on the official site: <a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a>` +
|
||||
`</div></div></div></body></html>`;
|
||||
return { html, text: `${text}\n\n--\nThis email is notification-only, do not reply. Respond at: ${reviewUrl}\n` };
|
||||
}
|
||||
|
||||
async function sendTicketMail(to: string, subject: string, title: string, bodyHtml: string, reviewUrl: string): Promise<boolean> {
|
||||
const t = await getTransporter();
|
||||
if (!t) {
|
||||
console.error("[mail] SMTP_HOST not set — ticket email skipped");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { html, text } = ticketLayout(title, bodyHtml, reviewUrl);
|
||||
await t.sendMail({ from: config.smtpFrom, to, subject, text, html });
|
||||
console.log(`[mail] ticket email "${subject}" sent`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[mail] send failed:", (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirm a wishlist request was received, with the /review ticket link. */
|
||||
export async function sendAccessReceivedEmail(to: string, username: string, reviewUrl: string): Promise<boolean> {
|
||||
return sendTicketMail(
|
||||
to,
|
||||
"KisAssistant — access request received",
|
||||
"Access request received",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p>We received your request for a KisAssistant account. An admin will review it shortly.</p>` +
|
||||
`<p>Follow the discussion and add arguments here:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>` +
|
||||
`<p>Keep this link — it is your personal ticket page.</p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/** Notify status changes. `key` is set only on acceptance (shown once). */
|
||||
export async function sendAccessStatusEmail(
|
||||
to: string,
|
||||
username: string,
|
||||
status: "reviewing" | "accepted" | "refused",
|
||||
reason: string,
|
||||
reviewUrl: string,
|
||||
key?: string,
|
||||
): Promise<boolean> {
|
||||
if (status === "accepted") {
|
||||
return sendTicketMail(
|
||||
to,
|
||||
"KisAssistant — access granted",
|
||||
"Access granted",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p>Good news — your KisAssistant account was created${reason ? ` (${escHtml(reason)})` : ""}.</p>` +
|
||||
`<p>Your access key (shown <strong>once</strong> — save it now):</p>` +
|
||||
`<p style="background:#f5f5f4;border-radius:8px;padding:12px 16px;font-family:monospace;font-size:15px;">${escHtml(key ?? "")}</p>` +
|
||||
`<p>Log in with your username and this key. You can still discuss on your ticket page:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
if (status === "refused") {
|
||||
return sendTicketMail(
|
||||
to,
|
||||
"KisAssistant — access request refused",
|
||||
"Access refused",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p>Your request for a KisAssistant account was refused${reason ? ` for this reason: <em>${escHtml(reason)}</em>` : ""}.</p>` +
|
||||
`<p>You may still reply on your ticket page if you think this is a mistake:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
return sendTicketMail(
|
||||
to,
|
||||
"KisAssistant — access request under review",
|
||||
"Under review",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p>An admin is now reviewing your request${reason ? `: <em>${escHtml(reason)}</em>` : ""}.</p>` +
|
||||
`<p>You can add arguments on your ticket page:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/** Forward each new ticket message to the requester (notification-only). */
|
||||
export async function sendAccessMessageEmail(
|
||||
to: string,
|
||||
username: string,
|
||||
fromAdmin: boolean,
|
||||
body: string,
|
||||
reviewUrl: string,
|
||||
): Promise<boolean> {
|
||||
const excerpt = body.length > 500 ? `${body.slice(0, 500)}…` : body;
|
||||
return sendTicketMail(
|
||||
to,
|
||||
`KisAssistant — new message on your access request`,
|
||||
"New ticket message",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p><strong>${fromAdmin ? "An admin" : "You"}</strong> wrote on your access-request ticket:</p>` +
|
||||
`<p style="background:#f5f5f4;border-left:4px solid #059669;border-radius:0 8px 8px 0;padding:12px 16px;white-space:pre-wrap;">${escHtml(excerpt)}</p>` +
|
||||
`<p>Reply on your ticket page:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
||||
Reference in New Issue
Block a user