v2.5.0:操作日志 + 邮件保留 + 账号弹窗精简 + 复制按钮推广
- 操作日志:operation_logs 表(0004 迁移)记录 账号/域名/邮箱/Webhook 全部写操作, 含操作账号(Console 登录名,系统动作为 system);/logs 日志页(类型筛选+搜索+分页); 上限 10000 条,Cron housekeeping 超限删最旧 - 邮件保留:emails.retained;POST /api/emails/retain(单条/批量); TTL 清理与 Webhook 投递过期判定均跳过已保留;/inboxs 操作列图钉按钮 + 批量保留 - 添加账号弹窗:OAuth 与 API Token 同窗按钮组切换,去掉说明文字与账户 ID 字段 (账户 ID 以 Token 实际所属账户为准);账号列表去掉 Email Worker 列 - 复制按钮推广:CopyText 组件;/domains 域名列、/inboxs 发件人与收件人(原「邮箱」改名)列 - 测试:104 bun tests(+10 日志/保留)、ui-smoke 35 项(+logs 页/新弹窗/复制/保留)
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
preflightOAuth,
|
||||
} from "../services/cfapi.ts";
|
||||
import { provisionAccount, createAccount } from "../services/provision.ts";
|
||||
import { logOperation } from "../services/logs.ts";
|
||||
import { saveConnection } from "../db.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
@@ -128,6 +129,18 @@ export function oauthRoutes(): App {
|
||||
expiresIn: token.expiresIn,
|
||||
});
|
||||
const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken });
|
||||
// 操作日志:回调不带会话 cookie(SameSite),操作账号记 system 并标注来源
|
||||
const acct = await c.env.DB.prepare("SELECT name FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(verdict.accountId)
|
||||
.first<{ name: string }>();
|
||||
await logOperation(c.env, {
|
||||
action: "account.connect",
|
||||
targetType: "account",
|
||||
targetId: verdict.accountId,
|
||||
targetName: acct?.name ?? null,
|
||||
detail: { provider: "cloudflare-oauth", ok: result.ok, error: result.error ?? null },
|
||||
operator: "system",
|
||||
});
|
||||
const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&ok=${result.ok ? 1 : 0}`;
|
||||
return c.redirect(target, 302);
|
||||
} catch (err) {
|
||||
|
||||
+277
-21
@@ -9,6 +9,8 @@
|
||||
* - 批量操作(POST .../batch-delete):四个资源列表的复选框批量删除
|
||||
* 账号=软删(撤销 Worker 凭据并标记 revoked);域名/邮箱=级联删除其下邮件;
|
||||
* 邮件=连同 R2 原文与附件一起删除
|
||||
* - POST /emails/retain 保留邮件(不再过期);GET /logs 操作日志查询(v2.5.0)
|
||||
* - 全部写操作写 operation_logs(操作账号 / 操作域名 / 操作邮箱 / Webhook 记录)
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
@@ -16,6 +18,7 @@ import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount } from "../services/provision.ts";
|
||||
import { getCFApi } from "../services/cfapi.ts";
|
||||
import { logOperation, operatorName } from "../services/logs.ts";
|
||||
import { encryptSecret, keyHash, decryptAccountToken, decryptSecret, saveConnection } from "../db.ts";
|
||||
import { checkWebhookUrl, ids } from "../../../shared/src/index.ts";
|
||||
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
|
||||
@@ -34,6 +37,7 @@ export function resourceRoutes(): App {
|
||||
installDomainRoutes(app);
|
||||
installMailboxRoutes(app);
|
||||
installEmailRoutes(app);
|
||||
installLogRoutes(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -116,13 +120,13 @@ async function deleteEmailsByMailbox(env: Env, mailboxId: string): Promise<numbe
|
||||
|
||||
/** # 函数
|
||||
* 软删账号:撤销 Worker credential + 标记 revoked(与 DELETE /accounts/:id 同一语义)
|
||||
* 返回值: 账号存在且未撤销时 true
|
||||
* 返回值: { deleted } 为账号存在且未撤销时 true;name 用于操作日志展示
|
||||
*/
|
||||
async function revokeAccount(env: Env, id: string): Promise<boolean> {
|
||||
const row = await env.DB.prepare("SELECT id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
|
||||
async function revokeAccount(env: Env, id: string): Promise<{ deleted: boolean; name: string | null }> {
|
||||
const row = await env.DB.prepare("SELECT id, name FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
|
||||
.bind(id)
|
||||
.first<{ id: string }>();
|
||||
if (!row) return false;
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!row) return { deleted: false, name: null };
|
||||
await env.DB.prepare(
|
||||
"UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'",
|
||||
)
|
||||
@@ -131,7 +135,7 @@ async function revokeAccount(env: Env, id: string): Promise<boolean> {
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), id)
|
||||
.run();
|
||||
return true;
|
||||
return { deleted: true, name: row.name };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
@@ -180,14 +184,22 @@ function installAccountRoutes(app: App) {
|
||||
app.patch("/accounts/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = await readJson<{ name?: string }>(c.req.raw);
|
||||
const account = await c.env.DB.prepare("SELECT id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
|
||||
const account = await c.env.DB.prepare("SELECT id, name FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
|
||||
.bind(id)
|
||||
.first();
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!account) return c.json({ error: "not found" }, 404);
|
||||
if (body.name !== undefined) {
|
||||
const name = body.name.trim();
|
||||
if (!name) return c.json({ error: "name required" }, 400);
|
||||
await c.env.DB.prepare("UPDATE cloudflare_accounts SET name = ?, updated_at = ? WHERE id = ?").bind(name, nowIso(), id).run();
|
||||
await logOperation(c.env, {
|
||||
action: "account.update",
|
||||
targetType: "account",
|
||||
targetId: id,
|
||||
targetName: name,
|
||||
detail: { old_name: account.name },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -195,7 +207,16 @@ function installAccountRoutes(app: App) {
|
||||
/** 删除账号(软删:撤销 Worker credential + 标记 revoked) */
|
||||
app.delete("/accounts/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await revokeAccount(c.env, id);
|
||||
const r = await revokeAccount(c.env, id);
|
||||
if (r.deleted) {
|
||||
await logOperation(c.env, {
|
||||
action: "account.delete",
|
||||
targetType: "account",
|
||||
targetId: id,
|
||||
targetName: r.name,
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -203,10 +224,25 @@ function installAccountRoutes(app: App) {
|
||||
app.post("/accounts/batch-delete", async (c) => {
|
||||
const ids = await readIds(c);
|
||||
const failed: { id: string; reason: string }[] = [];
|
||||
const deletedNames: string[] = [];
|
||||
let deleted = 0;
|
||||
for (const id of ids) {
|
||||
if (await revokeAccount(c.env, id)) deleted++;
|
||||
else failed.push({ id, reason: "账号不存在或已删除" });
|
||||
const r = await revokeAccount(c.env, id);
|
||||
if (r.deleted) {
|
||||
deleted++;
|
||||
if (r.name) deletedNames.push(r.name);
|
||||
} else {
|
||||
failed.push({ id, reason: "账号不存在或已删除" });
|
||||
}
|
||||
}
|
||||
if (deleted > 0) {
|
||||
await logOperation(c.env, {
|
||||
action: "account.batch_delete",
|
||||
targetType: "account",
|
||||
targetName: deletedNames.join("、"),
|
||||
detail: { deleted, ids },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true, deleted, failed });
|
||||
});
|
||||
@@ -216,10 +252,10 @@ function installAccountRoutes(app: App) {
|
||||
app.post("/accounts/:id/rotate", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const account = await c.env.DB.prepare(
|
||||
"SELECT id, worker_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
|
||||
"SELECT id, name, worker_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ id: string; worker_id: string | null }>();
|
||||
.first<{ id: string; name: string; worker_id: string | null }>();
|
||||
if (!account) return c.json({ error: "not found" }, 404);
|
||||
if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400);
|
||||
|
||||
@@ -259,6 +295,14 @@ function installAccountRoutes(app: App) {
|
||||
const r = await setupDomainEmail(c.env, d.id);
|
||||
if (r.ok) redeployed.push(d.name);
|
||||
}
|
||||
await logOperation(c.env, {
|
||||
action: "account.rotate",
|
||||
targetType: "account",
|
||||
targetId: id,
|
||||
targetName: account.name ?? null,
|
||||
detail: { credential_id: credentialId, worker_id: account.worker_id, redeployed },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
return c.json({ ok: true, worker_id: account.worker_id, credential_id: credentialId, secret, redeployed });
|
||||
});
|
||||
/** 本地/运维用途:查看账号 Worker credential(会话保护;真实部署由 provisioning 自动注入 Worker) */
|
||||
@@ -325,6 +369,14 @@ function installAccountRoutes(app: App) {
|
||||
accessToken: token,
|
||||
cfAccountId: discovered.account.id,
|
||||
});
|
||||
await logOperation(c.env, {
|
||||
action: "account.create",
|
||||
targetType: "account",
|
||||
targetId: created.accountId,
|
||||
targetName: name,
|
||||
detail: { provider: "cloudflare-token", cloudflare_account_id: discovered.account.id, ok: result.ok, error: result.error },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
return c.json({ ok: result.ok, account_id: created.accountId, error: result.error }, result.ok ? 200 : 400);
|
||||
});
|
||||
}
|
||||
@@ -355,34 +407,77 @@ function installDomainRoutes(app: App) {
|
||||
|
||||
/** 为域名配置 Email Workers(部署 Worker + Email Routing + catch-all) */
|
||||
app.post("/domains/:id/setup", async (c) => {
|
||||
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
|
||||
.bind(c.req.param("id"))
|
||||
.first<{ id: string; name: string }>();
|
||||
const result = await setupDomainEmail(c.env, c.req.param("id"));
|
||||
if (domain) {
|
||||
await logOperation(c.env, {
|
||||
action: "domain.setup",
|
||||
targetType: "domain",
|
||||
targetId: domain.id,
|
||||
targetName: domain.name,
|
||||
detail: { ok: result.ok, error: result.error ?? null },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json(result, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
/** 保存域名级 Webhook(url 为空表示清除) */
|
||||
app.put("/domains/:id/webhook", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const domain = await c.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(id).first();
|
||||
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?").bind(id).first<{ id: string; name: string }>();
|
||||
if (!domain) return c.json({ error: "not found" }, 404);
|
||||
const parsed = await parseWebhookBody(c);
|
||||
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
|
||||
await saveScopedWebhook(c.env, "domains", id, parsed);
|
||||
await logOperation(c.env, {
|
||||
action: parsed.url ? "webhook.save" : "webhook.clear",
|
||||
targetType: "webhook",
|
||||
targetId: id,
|
||||
targetName: parsed.url || domain.name,
|
||||
detail: { scope: "domain", domain: domain.name },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 清除域名级 Webhook */
|
||||
app.delete("/domains/:id/webhook", async (c) => {
|
||||
await saveScopedWebhook(c.env, "domains", c.req.param("id"), { url: "" });
|
||||
const id = c.req.param("id");
|
||||
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?").bind(id).first<{ id: string; name: string }>();
|
||||
await saveScopedWebhook(c.env, "domains", id, { url: "" });
|
||||
if (domain) {
|
||||
await logOperation(c.env, {
|
||||
action: "webhook.clear",
|
||||
targetType: "webhook",
|
||||
targetId: id,
|
||||
targetName: domain.name,
|
||||
detail: { scope: "domain", domain: domain.name },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/domains/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?").bind(id).first<{ id: string; name: string }>();
|
||||
const used = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?")
|
||||
.bind(id)
|
||||
.first<{ n: number }>();
|
||||
if ((used?.n ?? 0) > 0) return c.json({ error: "domain has mailboxes" }, 409);
|
||||
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
|
||||
if (domain) {
|
||||
await logOperation(c.env, {
|
||||
action: "domain.delete",
|
||||
targetType: "domain",
|
||||
targetId: id,
|
||||
targetName: domain.name,
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -392,10 +487,14 @@ function installDomainRoutes(app: App) {
|
||||
app.post("/domains/batch-delete", async (c) => {
|
||||
const ids = await readIds(c);
|
||||
const failed: { id: string; reason: string }[] = [];
|
||||
const deletedNames: string[] = [];
|
||||
let deleted = 0;
|
||||
let mailboxes = 0;
|
||||
let emails = 0;
|
||||
for (const id of ids) {
|
||||
const name = (
|
||||
await c.env.DB.prepare("SELECT name FROM domains WHERE id = ?").bind(id).first<{ name: string }>()
|
||||
)?.name ?? null;
|
||||
const boxes = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE domain_id = ?")
|
||||
.bind(id)
|
||||
.all<{ id: string }>();
|
||||
@@ -408,10 +507,20 @@ function installDomainRoutes(app: App) {
|
||||
if (changes > 0) {
|
||||
deleted++;
|
||||
mailboxes += boxes.results.length;
|
||||
if (name) deletedNames.push(name);
|
||||
} else {
|
||||
failed.push({ id, reason: "域名不存在" });
|
||||
}
|
||||
}
|
||||
if (deleted > 0) {
|
||||
await logOperation(c.env, {
|
||||
action: "domain.batch_delete",
|
||||
targetType: "domain",
|
||||
targetName: deletedNames.join("、"),
|
||||
detail: { deleted, mailboxes, emails, ids },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true, deleted, mailboxes, emails, failed });
|
||||
});
|
||||
}
|
||||
@@ -459,6 +568,14 @@ function installMailboxRoutes(app: App) {
|
||||
isCatchAll: Boolean(body.catch_all),
|
||||
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined,
|
||||
});
|
||||
await logOperation(c.env, {
|
||||
action: "mailbox.create",
|
||||
targetType: "mailbox",
|
||||
targetId: id,
|
||||
targetName: address,
|
||||
detail: { domain: domain.name, catch_all: Boolean(body.catch_all) },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
return c.json({ ok: true, id, address }, 201);
|
||||
});
|
||||
|
||||
@@ -468,7 +585,9 @@ function installMailboxRoutes(app: App) {
|
||||
if (body.status && !["active", "disabled"].includes(body.status)) {
|
||||
return c.json({ error: "invalid status" }, 400);
|
||||
}
|
||||
const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first();
|
||||
const mailbox = await c.env.DB.prepare("SELECT id, address FROM mailboxes WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ id: string; address: string }>();
|
||||
if (!mailbox) return c.json({ error: "not found" }, 404);
|
||||
if (body.status) {
|
||||
await c.env.DB.prepare("UPDATE mailboxes SET status = ?, updated_at = ? WHERE id = ?")
|
||||
@@ -480,29 +599,75 @@ function installMailboxRoutes(app: App) {
|
||||
.bind(body.retention_seconds ? Number(body.retention_seconds) : null, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
if (body.status || body.retention_seconds !== undefined) {
|
||||
await logOperation(c.env, {
|
||||
action: "mailbox.update",
|
||||
targetType: "mailbox",
|
||||
targetId: id,
|
||||
targetName: mailbox.address,
|
||||
detail: { status: body.status, retention_seconds: body.retention_seconds },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 保存邮箱级 Webhook(url 为空表示清除) */
|
||||
app.put("/mailboxes/:id/webhook", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first();
|
||||
const mailbox = await c.env.DB.prepare("SELECT id, address FROM mailboxes WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ id: string; address: string }>();
|
||||
if (!mailbox) return c.json({ error: "not found" }, 404);
|
||||
const parsed = await parseWebhookBody(c);
|
||||
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
|
||||
await saveScopedWebhook(c.env, "mailboxes", id, parsed);
|
||||
await logOperation(c.env, {
|
||||
action: parsed.url ? "webhook.save" : "webhook.clear",
|
||||
targetType: "webhook",
|
||||
targetId: id,
|
||||
targetName: parsed.url || mailbox.address,
|
||||
detail: { scope: "mailbox", mailbox: mailbox.address },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 清除邮箱级 Webhook */
|
||||
app.delete("/mailboxes/:id/webhook", async (c) => {
|
||||
await saveScopedWebhook(c.env, "mailboxes", c.req.param("id"), { url: "" });
|
||||
const id = c.req.param("id");
|
||||
const mailbox = await c.env.DB.prepare("SELECT id, address FROM mailboxes WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ id: string; address: string }>();
|
||||
await saveScopedWebhook(c.env, "mailboxes", id, { url: "" });
|
||||
if (mailbox) {
|
||||
await logOperation(c.env, {
|
||||
action: "webhook.clear",
|
||||
targetType: "webhook",
|
||||
targetId: id,
|
||||
targetName: mailbox.address,
|
||||
detail: { scope: "mailbox", mailbox: mailbox.address },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/mailboxes/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const mailbox = await c.env.DB.prepare("SELECT id, address FROM mailboxes WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ id: string; address: string }>();
|
||||
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
|
||||
if (mailbox) {
|
||||
await logOperation(c.env, {
|
||||
action: "mailbox.delete",
|
||||
targetType: "mailbox",
|
||||
targetId: id,
|
||||
targetName: mailbox.address,
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -510,14 +675,29 @@ function installMailboxRoutes(app: App) {
|
||||
app.post("/mailboxes/batch-delete", async (c) => {
|
||||
const ids = await readIds(c);
|
||||
const failed: { id: string; reason: string }[] = [];
|
||||
const deletedNames: string[] = [];
|
||||
let deleted = 0;
|
||||
let emails = 0;
|
||||
for (const id of ids) {
|
||||
const address = (
|
||||
await c.env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?").bind(id).first<{ address: string }>()
|
||||
)?.address ?? null;
|
||||
emails += await deleteEmailsByMailbox(c.env, id);
|
||||
const res = await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
|
||||
const changes = (res as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0;
|
||||
if (changes > 0) deleted++;
|
||||
else failed.push({ id, reason: "邮箱不存在" });
|
||||
if (changes > 0) {
|
||||
deleted++;
|
||||
if (address) deletedNames.push(address);
|
||||
} else failed.push({ id, reason: "邮箱不存在" });
|
||||
}
|
||||
if (deleted > 0) {
|
||||
await logOperation(c.env, {
|
||||
action: "mailbox.batch_delete",
|
||||
targetType: "mailbox",
|
||||
targetName: deletedNames.join("、"),
|
||||
detail: { deleted, emails, ids },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true, deleted, emails, failed });
|
||||
});
|
||||
@@ -550,7 +730,7 @@ function installEmailRoutes(app: App) {
|
||||
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT e.id, e.mailbox_id, e.message_id, e.from_address, e.to_addresses, e.subject, e.size,
|
||||
e.received_at, e.expires_at, m.address AS mailbox_address,
|
||||
e.received_at, e.expires_at, e.retained, m.address AS mailbox_address,
|
||||
(SELECT COUNT(*) FROM email_attachments a WHERE a.email_id = e.id) AS attachment_count
|
||||
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
|
||||
${where} ORDER BY e.received_at DESC LIMIT ? OFFSET ?`,
|
||||
@@ -615,7 +795,17 @@ function installEmailRoutes(app: App) {
|
||||
|
||||
app.delete("/emails/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const target = await c.env.DB.prepare("SELECT subject, from_address FROM emails WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ subject: string | null; from_address: string | null }>();
|
||||
if (!(await deleteEmailById(c.env, id))) return c.json({ error: "not found" }, 404);
|
||||
await logOperation(c.env, {
|
||||
action: "email.delete",
|
||||
targetType: "email",
|
||||
targetId: id,
|
||||
targetName: target?.subject ?? target?.from_address ?? null,
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -628,6 +818,72 @@ function installEmailRoutes(app: App) {
|
||||
if (await deleteEmailById(c.env, id)) deleted++;
|
||||
else failed.push({ id, reason: "邮件不存在或已过期清理" });
|
||||
}
|
||||
if (deleted > 0) {
|
||||
await logOperation(c.env, {
|
||||
action: "email.batch_delete",
|
||||
targetType: "email",
|
||||
detail: { deleted, ids },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true, deleted, failed });
|
||||
});
|
||||
|
||||
/** 保留邮件(单条/批量):retained=1 后 Cron TTL 清理跳过,永不过期 */
|
||||
app.post("/emails/retain", async (c) => {
|
||||
const ids = await readIds(c);
|
||||
let retained = 0;
|
||||
for (const id of ids) {
|
||||
const res = await c.env.DB.prepare("UPDATE emails SET retained = 1 WHERE id = ? AND retained = 0")
|
||||
.bind(id)
|
||||
.run();
|
||||
retained += (res as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0;
|
||||
}
|
||||
if (retained > 0) {
|
||||
await logOperation(c.env, {
|
||||
action: "email.retain",
|
||||
targetType: "email",
|
||||
detail: { retained, ids },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true, retained });
|
||||
});
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 操作日志查询(日志页):按对象类型筛选 + 分页,时间倒序
|
||||
*/
|
||||
function installLogRoutes(app: App) {
|
||||
app.get("/logs", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const { limit, offset } = pageParams(url);
|
||||
const targetType = url.searchParams.get("target_type");
|
||||
const action = url.searchParams.get("action");
|
||||
const q = url.searchParams.get("q");
|
||||
const conds: string[] = [];
|
||||
const binds: unknown[] = [];
|
||||
if (targetType) {
|
||||
conds.push("target_type = ?");
|
||||
binds.push(targetType);
|
||||
}
|
||||
if (action) {
|
||||
conds.push("action = ?");
|
||||
binds.push(action);
|
||||
}
|
||||
if (q) {
|
||||
conds.push("(target_name LIKE ? OR operator LIKE ?)");
|
||||
binds.push(`%${q}%`, `%${q}%`);
|
||||
}
|
||||
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT id, action, target_type, target_id, target_name, detail, operator, created_at
|
||||
FROM operation_logs ${where} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
|
||||
);
|
||||
const res = await (binds.length ? rows.bind(...binds, limit, offset) : rows.bind(limit, offset)).all();
|
||||
const totalRows = binds.length
|
||||
? await c.env.DB.prepare(`SELECT COUNT(*) AS n FROM operation_logs ${where}`).bind(...binds).first<{ n: number }>()
|
||||
: await c.env.DB.prepare("SELECT COUNT(*) AS n FROM operation_logs").first<{ n: number }>();
|
||||
return c.json({ items: res.results, total: totalRows?.n ?? 0 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,11 +29,8 @@ import {
|
||||
oauthRedirectUri,
|
||||
saveOAuthClient,
|
||||
} from "../services/cfapi.ts";
|
||||
import {
|
||||
getGlobalWebhookKey,
|
||||
normalizeWebhookHeaders,
|
||||
saveGlobalWebhook,
|
||||
} from "../services/webhook_delivery.ts";
|
||||
import { getGlobalWebhookKey, normalizeWebhookHeaders, saveGlobalWebhook } from "../services/webhook_delivery.ts";
|
||||
import { logOperation, operatorName } from "../services/logs.ts";
|
||||
import { nowIso, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
@@ -112,6 +109,13 @@ export function settingsRoutes(): App {
|
||||
headers,
|
||||
key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey,
|
||||
});
|
||||
await logOperation(c.env, {
|
||||
action: url ? "webhook.save" : "webhook.clear",
|
||||
targetType: "webhook",
|
||||
targetName: url || "全局 Webhook",
|
||||
detail: { scope: "global" },
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
|
||||
if (body.cf_oauth) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* R2 失败则保留 DB 记录留待下次 Cron 重试(幂等)。
|
||||
* - cleanupOrphans:附件行存在但邮件已删 -> 删 R2 对象 + 附件行;
|
||||
* delivery 指向已删邮件 -> 删 delivery 行(数据最小化)。
|
||||
* - housekeeping:过期 nonce、过期 OAuth state 清理。
|
||||
* - housekeeping:过期 nonce、过期 OAuth state 清理;操作日志超限裁剪(最多 10000 条,删最旧)。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
@@ -49,11 +49,12 @@ export async function runCron(
|
||||
|
||||
/** # 函数
|
||||
* 删除过期邮件(R2 先行,全部成功才删 DB)
|
||||
* 「保留」(retained=1)的邮件跳过:用户点名要留的邮件不受 TTL 约束
|
||||
*/
|
||||
export async function cleanupExpiredEmails(env: Env, stats: CleanupStats): Promise<void> {
|
||||
const now = nowIso();
|
||||
const expired = await env.DB.prepare(
|
||||
"SELECT id, raw_storage_key FROM emails WHERE expires_at <= ? LIMIT 100",
|
||||
"SELECT id, raw_storage_key FROM emails WHERE expires_at <= ? AND retained = 0 LIMIT 100",
|
||||
)
|
||||
.bind(now)
|
||||
.all<{ id: string; raw_storage_key: string }>();
|
||||
@@ -105,12 +106,20 @@ export async function cleanupOrphans(env: Env, stats: CleanupStats): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
/** # 成员变量: 操作日志保留上限(超出删最旧;用户指定 10000 条) **/
|
||||
const OPERATION_LOG_MAX = 10000;
|
||||
|
||||
/** # 函数
|
||||
* 琐碎清理:过期 nonce、过期 OAuth state
|
||||
* 琐碎清理:过期 nonce、过期 OAuth state、操作日志超限裁剪
|
||||
*/
|
||||
export async function housekeeping(env: Env, stats: CleanupStats): Promise<void> {
|
||||
const now = nowIso();
|
||||
const n1 = await env.DB.prepare("DELETE FROM nonces WHERE expires_at <= ?").bind(now).run();
|
||||
const n2 = await env.DB.prepare("DELETE FROM oauth_states WHERE expires_at <= ?").bind(now).run();
|
||||
stats.housekeeping += (n1.meta?.changes ?? 0) + (n2.meta?.changes ?? 0);
|
||||
// 操作日志最多保留 OPERATION_LOG_MAX 条:按时间倒序留新删旧
|
||||
const n3 = await env.DB.prepare(
|
||||
`DELETE FROM operation_logs WHERE id NOT IN
|
||||
(SELECT id FROM operation_logs ORDER BY created_at DESC, id DESC LIMIT ${OPERATION_LOG_MAX})`,
|
||||
).run();
|
||||
stats.housekeeping += (n1.meta?.changes ?? 0) + (n2.meta?.changes ?? 0) + (n3.meta?.changes ?? 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 操作日志(docs:v2.5.0):Console 内所有人工/系统操作的统一落点。
|
||||
* - 写入方:资源路由(账号/域名/邮箱/Webhook 配置)、OAuth 接入、Cron 投递结局。
|
||||
* - detail 只放非敏感元信息(id、url、数量、错误消息);secret / token / key 永不入该表。
|
||||
* - logOperation 自身失败只记 console,绝不打断业务操作(日志是旁路,不能反噬主流程)。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import { ids } from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 操作对象类型 **/
|
||||
export type OpTargetType = "account" | "domain" | "mailbox" | "email" | "webhook";
|
||||
|
||||
/** # 成员变量: 写一条操作日志的参数 **/
|
||||
export interface OperationLogInput {
|
||||
/** 动作名,如 account.create / domain.setup / webhook.deliver **/
|
||||
action: string;
|
||||
/** 操作对象类型 **/
|
||||
targetType: OpTargetType;
|
||||
/** 被操作资源 id(无 id 的全局配置可空) **/
|
||||
targetId?: string | null;
|
||||
/** 展示名(账号名 / 域名 / 邮箱地址 / webhook url) **/
|
||||
targetName?: string | null;
|
||||
/** JSON 补充信息(不含 secret) **/
|
||||
detail?: Record<string, unknown> | null;
|
||||
/** 操作账号(Console 登录名);系统自动操作传 system **/
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 写一条操作日志(旁路:任何失败只打日志,不抛错)
|
||||
* 参数: env - 运行环境;input - 日志内容
|
||||
*/
|
||||
export async function logOperation(env: Env, input: OperationLogInput): Promise<void> {
|
||||
try {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO operation_logs (id, action, target_type, target_id, target_name, detail, operator, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
ids.log(),
|
||||
input.action,
|
||||
input.targetType,
|
||||
input.targetId ?? null,
|
||||
input.targetName ?? null,
|
||||
input.detail && Object.keys(input.detail).length > 0 ? JSON.stringify(input.detail) : null,
|
||||
input.operator ?? "system",
|
||||
nowIso(),
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
JSON.stringify({ t: nowIso(), lvl: "error", msg: "operation log write failed", action: input.action, error: String(err) }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 按用户 id 查登录名(日志的 operator 字段用)
|
||||
* 返回值: 用户名;查不到回退为用户 id
|
||||
*/
|
||||
export async function operatorName(env: Env, userId: string | undefined): Promise<string> {
|
||||
if (!userId) return "system";
|
||||
const row = await env.DB.prepare("SELECT username FROM users WHERE id = ?")
|
||||
.bind(userId)
|
||||
.first<{ username: string }>();
|
||||
return row?.username ?? userId;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../../../shared/src/index.ts";
|
||||
import { htmlToMarkdown } from "../../../shared/src/index.ts";
|
||||
import { encryptSecret, decryptSecret, getAesKey, getSetting, setSetting } from "../db.ts";
|
||||
import { logOperation } from "./logs.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 单级 Webhook 配置(解析结果) **/
|
||||
@@ -317,6 +318,7 @@ interface DueDeliveryRow {
|
||||
markdown_body: string | null;
|
||||
received_at: string;
|
||||
expires_at: string;
|
||||
retained: number;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
@@ -333,7 +335,7 @@ export async function processDueDeliveries(
|
||||
const due = await env.DB.prepare(
|
||||
`SELECT d.id AS delivery_id, d.url, d.headers, d.key_enc, d.attempt_count,
|
||||
e.id AS email_id, e.mailbox_id, e.from_address, e.to_addresses, e.subject, e.text_body,
|
||||
e.html_body, e.markdown_body, e.received_at, e.expires_at
|
||||
e.html_body, e.markdown_body, e.received_at, e.expires_at, e.retained
|
||||
FROM webhook_deliveries d
|
||||
JOIN emails e ON e.id = d.email_id
|
||||
WHERE d.status IN ('pending', 'failed')
|
||||
@@ -349,8 +351,8 @@ export async function processDueDeliveries(
|
||||
let failed = 0;
|
||||
let exhausted = 0;
|
||||
for (const row of due.results) {
|
||||
if (row.expires_at <= now) {
|
||||
// 邮件已过期:投递失去意义,直接结束(数据最小化)
|
||||
if (row.expires_at <= now && !row.retained) {
|
||||
// 邮件已过期:投递失去意义,直接结束(数据最小化);「保留」的邮件不受过期约束
|
||||
await env.DB.prepare("UPDATE webhook_deliveries SET status = 'exhausted', last_error = 'email expired' WHERE id = ?")
|
||||
.bind(row.delivery_id)
|
||||
.run();
|
||||
@@ -390,6 +392,14 @@ export async function processDueDeliveries(
|
||||
)
|
||||
.bind(attempts, nowIso(), row.delivery_id)
|
||||
.run();
|
||||
// 投递终局写操作日志(Webhook 记录):重试中间态不记,只记成功/彻底失败
|
||||
await logOperation(env, {
|
||||
action: "webhook.deliver",
|
||||
targetType: "webhook",
|
||||
targetId: row.delivery_id,
|
||||
targetName: row.url,
|
||||
detail: { status: "success", target_email_id: row.email_id, subject: row.subject, attempts },
|
||||
});
|
||||
success++;
|
||||
} else if (attempts >= WEBHOOK_MAX_ATTEMPTS) {
|
||||
await env.DB.prepare(
|
||||
@@ -397,6 +407,13 @@ export async function processDueDeliveries(
|
||||
)
|
||||
.bind(attempts, result.error ?? "unknown", row.delivery_id)
|
||||
.run();
|
||||
await logOperation(env, {
|
||||
action: "webhook.deliver_failed",
|
||||
targetType: "webhook",
|
||||
targetId: row.delivery_id,
|
||||
targetName: row.url,
|
||||
detail: { status: "exhausted", target_email_id: row.email_id, subject: row.subject, attempts, error: result.error ?? "unknown" },
|
||||
});
|
||||
exhausted++;
|
||||
} else {
|
||||
await env.DB.prepare(
|
||||
|
||||
Reference in New Issue
Block a user