feat: v2.3.0 设置内配置 Cloudflare OAuth 客户端 + 全列表批量删除
- 设置新增「Cloudflare 接入」tab:Client ID / Client Secret(Secret AES-GCM 加密入库,
只回显末 4 位),并展示可复制的授权回调地址;settings 优先、env 变量兜底,
保存后立即生效无需重新部署;DELETE /api/settings/cf-oauth 清除
- 四个资源列表(账号/域名/邮箱/收件箱)加复选框批量选择与批量删除
- 新增 POST /api/{accounts,domains,mailboxes,emails}/batch-delete:
账号软删+撤销凭据;域名/邮箱级联删除其下邮件;邮件连 R2 原文与附件一并清理
- 抽出 deleteEmailById / deleteEmailsByMailbox / revokeAccount 供单条与批量共用
- 点击复选框不再触发行点击(避免误开详情抽屉)
- 新增 tests/batch.test.ts(6 例)与 settings OAuth 凭据用例(2 例);ui-smoke 扩到 17 步
This commit is contained in:
@@ -73,6 +73,8 @@ export CF_OAUTH_REDIRECT_URI=http://127.0.0.1:18787/api/oauth/cloudflare/callbac
|
|||||||
|
|
||||||
- **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie;密码在「设置」中修改。
|
- **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie;密码在「设置」中修改。
|
||||||
- **账号管理**:「添加账号」直达 Cloudflare OAuth → 自动创建账号并同步该账号下全部域名、部署 Email Worker、签发 Worker 凭据;支持凭据轮换(轮换后已配置域名自动重新部署)。
|
- **账号管理**:「添加账号」直达 Cloudflare OAuth → 自动创建账号并同步该账号下全部域名、部署 Email Worker、签发 Worker 凭据;支持凭据轮换(轮换后已配置域名自动重新部署)。
|
||||||
|
- **Cloudflare 接入配置**:设置 →「Cloudflare 接入」填写 OAuth Client ID / Secret(Secret AES-GCM 加密入库,只回显末 4 位),保存后立即生效、无需重新部署;未配置时「添加账号」返回 503。
|
||||||
|
- **批量操作**:账号 / 域名 / 邮箱 / 收件箱四个列表均支持复选框选择与批量删除。账号为软删(撤销 Worker 凭据);域名与邮箱级联删除其下邮件;邮件连同 R2 原文与附件一并清理。
|
||||||
- **域名**:由 OAuth 自动同步(无手工添加);点击域名「设置 Email Workers」= 确保账号级 Worker 部署 + 开启 Email Routing + catch-all 规则;支持按账号筛选。
|
- **域名**:由 OAuth 自动同步(无手工添加);点击域名「设置 Email Workers」= 确保账号级 Worker 部署 + 开启 Email Routing + catch-all 规则;支持按账号筛选。
|
||||||
- **两阶段接收**:authorize(SMTP 决策:accept/reject/temporary_error,拒绝码 DOMAIN_NOT_FOUND / DOMAIN_DISABLED / MAILBOX_NOT_FOUND / MAILBOX_DISABLED / RECEIVING_DISABLED)+ ingest(raw MIME 入库);动态邮箱创建/删除/禁用下一封邮件立即生效。
|
- **两阶段接收**:authorize(SMTP 决策:accept/reject/temporary_error,拒绝码 DOMAIN_NOT_FOUND / DOMAIN_DISABLED / MAILBOX_NOT_FOUND / MAILBOX_DISABLED / RECEIVING_DISABLED)+ ingest(raw MIME 入库);动态邮箱创建/删除/禁用下一封邮件立即生效。
|
||||||
- **邮件管线**:MIME 解析(postal-mime)、附件、catch-all/精确匹配、大小限额、malformed 降级入库。
|
- **邮件管线**:MIME 解析(postal-mime)、附件、catch-all/精确匹配、大小限额、malformed 降级入库。
|
||||||
@@ -123,7 +125,11 @@ WmailHub/
|
|||||||
3. 依次执行 `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` … `0004_single_key_markdown.sql`(0001~0004 按序号全部执行,0004 依赖 0001 的 api_credentials)
|
3. 依次执行 `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` … `0004_single_key_markdown.sql`(0001~0004 按序号全部执行,0004 依赖 0001 的 api_credentials)
|
||||||
4. `cd console && bunx wrangler deploy`(前端先 `bun run build`);`PUBLIC_BASE_URL` 即 Email Worker 注入的 CONSOLE_URL
|
4. `cd console && bunx wrangler deploy`(前端先 `bun run build`);`PUBLIC_BASE_URL` 即 Email Worker 注入的 CONSOLE_URL
|
||||||
5. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串)
|
5. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串)
|
||||||
6. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`。**这是接入 Cloudflare 账号的硬前提**:未配置时 `/api/oauth/cloudflare/start` 与 `/cloudflare/callback` 直接返回 503,系统不会降级到任何模拟通道
|
6. Cloudflare Dashboard 创建 OAuth App(client id/secret),回调填 `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`(页面里会显示这个地址,直接复制)。Client ID / Secret 有两个配置位置,任一即可:
|
||||||
|
- **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署;
|
||||||
|
- 或部署时用 wrangler 配成 vars/secret(`CF_OAUTH_CLIENT_ID` / `CF_OAUTH_CLIENT_SECRET`),作为后台未配置时的兜底。
|
||||||
|
|
||||||
|
两者都没有时,`/api/oauth/cloudflare/start` 与 `/cloudflare/callback` 直接返回 503,系统不会降级到任何模拟通道
|
||||||
|
|
||||||
> 提示:`wrangler.jsonc` 里的 `database_id` 必须先填真实值,否则 `d1 execute` / `deploy` 都会报 `Invalid uuid`。
|
> 提示:`wrangler.jsonc` 里的 `database_id` 必须先填真实值,否则 `d1 execute` / `deploy` 都会报 `Invalid uuid`。
|
||||||
> 部署后自检:`curl https://wmailhub.adx9.com/api/health` → `{"ok":true,"name":"WmailHub","version":"<package.json 版本>"}`。
|
> 部署后自检:`curl https://wmailhub.adx9.com/api/health` → `{"ok":true,"name":"WmailHub","version":"<package.json 版本>"}`。
|
||||||
|
|||||||
@@ -266,21 +266,63 @@ export const getEmail = (id: string) => api<EmailDetail>(`/api/emails/${id}`);
|
|||||||
|
|
||||||
export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
|
export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
|
||||||
|
|
||||||
|
/* ---- 批量删除(列表复选框) ---- */
|
||||||
|
|
||||||
|
/** # 成员变量: 批量删除结果 **/
|
||||||
|
export interface BatchDeleteResult {
|
||||||
|
ok: boolean;
|
||||||
|
/** 成功删除的主记录数 **/
|
||||||
|
deleted: number;
|
||||||
|
/** 级联删除的邮箱数(域名批量删除时有值) **/
|
||||||
|
mailboxes?: number;
|
||||||
|
/** 级联删除的邮件数(域名/邮箱批量删除时有值) **/
|
||||||
|
emails?: number;
|
||||||
|
/** 删除失败的 id 及原因(不存在/已删除) **/
|
||||||
|
failed: { id: string; reason: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const batchDeleteAccounts = (ids: string[]) =>
|
||||||
|
api<BatchDeleteResult>("/api/accounts/batch-delete", "POST", { ids });
|
||||||
|
|
||||||
|
export const batchDeleteDomains = (ids: string[]) =>
|
||||||
|
api<BatchDeleteResult>("/api/domains/batch-delete", "POST", { ids });
|
||||||
|
|
||||||
|
export const batchDeleteMailboxes = (ids: string[]) =>
|
||||||
|
api<BatchDeleteResult>("/api/mailboxes/batch-delete", "POST", { ids });
|
||||||
|
|
||||||
|
export const batchDeleteEmails = (ids: string[]) =>
|
||||||
|
api<BatchDeleteResult>("/api/emails/batch-delete", "POST", { ids });
|
||||||
|
|
||||||
/* ---- 设置 ---- */
|
/* ---- 设置 ---- */
|
||||||
|
|
||||||
export interface SettingsInfo {
|
export interface SettingsInfo {
|
||||||
username: string;
|
username: string;
|
||||||
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
|
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
|
||||||
api_key: { has_key: boolean; key_last4: string; created_at: string | null };
|
api_key: { has_key: boolean; key_last4: string; created_at: string | null };
|
||||||
|
/** Cloudflare OAuth 客户端凭据元信息(不含明文 secret) */
|
||||||
|
cf_oauth: {
|
||||||
|
client_id: string;
|
||||||
|
has_secret: boolean;
|
||||||
|
secret_last4: string;
|
||||||
|
/** settings = 后台设置;env = 部署变量兜底;none = 未配置 */
|
||||||
|
source: "settings" | "env" | "none";
|
||||||
|
};
|
||||||
|
/** 需要在 Cloudflare Dashboard 登记的授权回调地址 */
|
||||||
|
redirect_uri: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSettings = () => api<SettingsInfo>("/api/settings");
|
export const getSettings = () => api<SettingsInfo>("/api/settings");
|
||||||
|
|
||||||
/** 保存设置;webhook.key:undefined 保留原值、空串清除、非空设置新 key */
|
/** 保存设置
|
||||||
|
* webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值 */
|
||||||
export const putSettings = (body: {
|
export const putSettings = (body: {
|
||||||
webhook?: { url: string; headers?: Record<string, string>; key?: string };
|
webhook?: { url: string; headers: Record<string, string>; key?: string };
|
||||||
|
cf_oauth?: { client_id: string; secret?: string };
|
||||||
}) => api("/api/settings", "PUT", body);
|
}) => api("/api/settings", "PUT", body);
|
||||||
|
|
||||||
|
/** 清除后台保存的 Cloudflare OAuth 客户端凭据(env 兜底不受影响) */
|
||||||
|
export const deleteCfOAuth = () => api("/api/settings/cf-oauth", "DELETE");
|
||||||
|
|
||||||
export const changePassword = (old_password: string, new_password: string) =>
|
export const changePassword = (old_password: string, new_password: string) =>
|
||||||
api("/api/settings/password", "PUT", { old_password, new_password });
|
api("/api/settings/password", "PUT", { old_password, new_password });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 列表批量操作(v2.3.0):
|
||||||
|
* 各资源列表共用的复选框选中状态 hook + 工具条(已选计数 / 清除选择 / 批量删除)。
|
||||||
|
*/
|
||||||
|
import { useState, type Key } from "react";
|
||||||
|
import { Button, Popconfirm, Space, Typography } from "antd";
|
||||||
|
import { DeleteOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
/** # 成员变量: 行选择器配置(直接展开到 antd Table 的 rowSelection) **/
|
||||||
|
export type RowSelection = {
|
||||||
|
type: "checkbox";
|
||||||
|
selectedRowKeys: Key[];
|
||||||
|
onChange: (keys: Key[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 复选框选中状态 hook:返回 Table 的 rowSelection 配置、选中的字符串 id 列表与清空方法
|
||||||
|
*/
|
||||||
|
export function useRowSelection(): {
|
||||||
|
rowSelection: RowSelection;
|
||||||
|
/** 选中的行 id(字符串形式,可直接提交给批量接口) **/
|
||||||
|
ids: string[];
|
||||||
|
count: number;
|
||||||
|
clear: () => void;
|
||||||
|
} {
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||||
|
return {
|
||||||
|
rowSelection: { type: "checkbox", selectedRowKeys, onChange: setSelectedRowKeys },
|
||||||
|
ids: selectedRowKeys.map((k) => String(k)),
|
||||||
|
count: selectedRowKeys.length,
|
||||||
|
clear: () => setSelectedRowKeys([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量: 批量操作工具条属性 **/
|
||||||
|
interface BatchActionsProps {
|
||||||
|
/** 已选数量;0 时不渲染 **/
|
||||||
|
count: number;
|
||||||
|
/** 清除选择 */
|
||||||
|
onClear: () => void;
|
||||||
|
/** 确认删除后执行 */
|
||||||
|
onDelete: () => void | Promise<void>;
|
||||||
|
/** 删除确认标题(含数量) */
|
||||||
|
title: string;
|
||||||
|
/** 删除影响范围说明(域名/邮箱级联删除时必填) */
|
||||||
|
description?: string;
|
||||||
|
/** 删除进行中 */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 批量操作工具条:未选中时渲染 null,选中后出现在列表工具栏右侧
|
||||||
|
*/
|
||||||
|
export function BatchActions({ count, onClear, onDelete, title, description, loading }: BatchActionsProps) {
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Typography.Text type="secondary">已选 {count} 项</Typography.Text>
|
||||||
|
<Button onClick={onClear} disabled={loading}>
|
||||||
|
清除选择
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title={title} description={description} onConfirm={() => void onDelete()}>
|
||||||
|
<Button danger icon={<DeleteOutlined />} loading={loading}>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 设置模态窗口(v2.1.0):
|
* 设置模态窗口(v2.3.0):
|
||||||
* - 两个 tab:全局 Webhook(含 API/MCP Key 卡片)、账号密码
|
* - 三个 tab:全局 Webhook(含 API/MCP Key 卡片)、Cloudflare 接入、账号密码
|
||||||
* - 单一 API/MCP Key(API 与 MCP 共用),凭据存 settings 表,明文仅生成时返回一次
|
* - 单一 API/MCP Key(API 与 MCP 共用),凭据存 settings 表,明文仅生成时返回一次
|
||||||
|
* - Cloudflare OAuth 客户端(Client ID / Secret)也在设置里维护,Secret 加密存储只回 last4,
|
||||||
|
* 保存后立即生效,无需重新部署;未配置时账号页「添加账号」不可用
|
||||||
* - 内容高度不超过屏幕(超出滚动)
|
* - 内容高度不超过屏幕(超出滚动)
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
@@ -20,12 +22,21 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
|
CloudServerOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { changePassword, createApiKey, deleteApiKey, getSettings, putSettings, type SettingsInfo } from "../api.ts";
|
import {
|
||||||
|
changePassword,
|
||||||
|
createApiKey,
|
||||||
|
deleteApiKey,
|
||||||
|
deleteCfOAuth,
|
||||||
|
getSettings,
|
||||||
|
putSettings,
|
||||||
|
type SettingsInfo,
|
||||||
|
} from "../api.ts";
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 设置模态窗口组件
|
* 设置模态窗口组件
|
||||||
@@ -61,6 +72,7 @@ export function SettingsModal({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
<Tabs
|
<Tabs
|
||||||
items={[
|
items={[
|
||||||
{ key: "webhook", label: "全局 Webhook", children: info ? <WebhookTab info={info} onSaved={load} /> : null },
|
{ key: "webhook", label: "全局 Webhook", children: info ? <WebhookTab info={info} onSaved={load} /> : null },
|
||||||
|
{ key: "cloudflare", label: <span><CloudServerOutlined style={{ marginRight: 6 }} />Cloudflare 接入</span>, children: info ? <CloudflareTab info={info} onSaved={load} /> : null },
|
||||||
{ key: "password", label: "账号密码", children: <PasswordTab /> },
|
{ key: "password", label: "账号密码", children: <PasswordTab /> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -251,6 +263,125 @@ function ApiKeyCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** # 成员变量: Cloudflare OAuth 客户端表单值 **/
|
||||||
|
interface CfOAuthFormValues {
|
||||||
|
client_id: string;
|
||||||
|
secret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* Cloudflare 接入 tab:维护 OAuth 客户端凭据 + 展示授权回调地址与申请权限
|
||||||
|
*/
|
||||||
|
function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [form] = Form.useForm<CfOAuthFormValues>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const meta = info.cf_oauth;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue({ client_id: meta.client_id });
|
||||||
|
}, [meta.client_id, form]);
|
||||||
|
|
||||||
|
/** 保存 OAuth 客户端凭据 */
|
||||||
|
const save = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await putSettings({ cf_oauth: { client_id: values.client_id.trim(), secret: values.secret } });
|
||||||
|
message.success("Cloudflare OAuth 客户端已保存");
|
||||||
|
form.setFieldValue("secret", undefined);
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 清除后台保存的凭据 */
|
||||||
|
const clear = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await deleteCfOAuth();
|
||||||
|
message.success("已清除后台保存的凭据");
|
||||||
|
form.resetFields();
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "清除失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||||
|
<Alert
|
||||||
|
type={meta.source === "none" ? "warning" : "success"}
|
||||||
|
showIcon
|
||||||
|
message={
|
||||||
|
meta.source === "none"
|
||||||
|
? "尚未配置 Cloudflare OAuth 客户端:账号页「添加账号」会返回 503,无法接入 Cloudflare 账号。"
|
||||||
|
: `已配置(来源:${meta.source === "settings" ? "后台设置" : "部署环境变量"}),账号页「添加账号」可正常发起授权。`
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
在 Cloudflare Dashboard 的 <a href="https://dash.cloudflare.com/profile/oauth-apps" target="_blank" rel="noreferrer">OAuth Applications</a>{" "}
|
||||||
|
新建应用,把下面的回调地址填入 Redirect URL,并勾选 Account:Read / Zone:Read /
|
||||||
|
Email Routing:Edit / Workers Scripts:Edit 四项权限。
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="授权回调地址(Redirect URL)">
|
||||||
|
<Typography.Paragraph
|
||||||
|
copyable={{ text: info.redirect_uri }}
|
||||||
|
className="mono"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
{info.redirect_uri}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="client_id"
|
||||||
|
label="Client ID"
|
||||||
|
rules={[{ required: true, message: "请填写 Client ID" }]}
|
||||||
|
extra="Cloudflare OAuth 应用的 Client ID"
|
||||||
|
>
|
||||||
|
<Input placeholder="Cloudflare OAuth Client ID" autoComplete="off" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="secret"
|
||||||
|
label="Client Secret"
|
||||||
|
extra={
|
||||||
|
meta.source === "settings" && meta.has_secret
|
||||||
|
? `已保存(****${meta.secret_last4}),留空保留现有,填空格清除`
|
||||||
|
: "仅此一次可见;AES-GCM 加密入库,保存后只回显末 4 位"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="留空保留现有 Secret" autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
{meta.source === "settings" && (
|
||||||
|
<Popconfirm
|
||||||
|
title="清除后台保存的 OAuth 凭据?"
|
||||||
|
description="若未配置部署环境变量兜底,清除后账号页将无法添加账号。"
|
||||||
|
onConfirm={() => void clear()}
|
||||||
|
>
|
||||||
|
<Button danger icon={<DeleteOutlined />} disabled={saving}>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 账号密码 tab:修改登录密码
|
* 账号密码 tab:修改登录密码
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "antd";
|
} from "antd";
|
||||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
|
batchDeleteAccounts,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
getAccount,
|
getAccount,
|
||||||
listAccounts,
|
listAccounts,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
type AccountDetail,
|
type AccountDetail,
|
||||||
type AccountRow,
|
type AccountRow,
|
||||||
} from "../api.ts";
|
} from "../api.ts";
|
||||||
|
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||||
|
|
||||||
/** # 成员变量: 账号状态 -> Tag 颜色 **/
|
/** # 成员变量: 账号状态 -> Tag 颜色 **/
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
@@ -52,6 +54,8 @@ export function AccountsPage() {
|
|||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [detail, setDetail] = useState<AccountDetail | null>(null);
|
const [detail, setDetail] = useState<AccountDetail | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -120,6 +124,26 @@ export function AccountsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
|
<BatchActions
|
||||||
|
count={count}
|
||||||
|
onClear={clear}
|
||||||
|
loading={deleting}
|
||||||
|
title={`删除选中的 ${count} 个账号?`}
|
||||||
|
description="账号的 Worker 凭据会被撤销(已部署的 Email Worker 随后无法再向 Console 投递);域名与邮箱数据保留。"
|
||||||
|
onDelete={async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await batchDeleteAccounts(selectedIds);
|
||||||
|
message.success(`已删除 ${res.deleted} 个账号`);
|
||||||
|
clear();
|
||||||
|
void load();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "批量删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
|
||||||
添加账号
|
添加账号
|
||||||
@@ -132,8 +156,16 @@ export function AccountsPage() {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={filtered}
|
dataSource={filtered}
|
||||||
|
rowSelection={rowSelection}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
onRow={(row) => ({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })}
|
onRow={(row) => ({
|
||||||
|
onClick: (e) => {
|
||||||
|
// 点击复选框不打开详情抽屉
|
||||||
|
if ((e.target as HTMLElement).closest(".ant-table-selection-cell")) return;
|
||||||
|
void openDetail(row.id);
|
||||||
|
},
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
})}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "名称", dataIndex: "name" },
|
{ title: "名称", dataIndex: "name" },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
|
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
|
||||||
import { CloudUploadOutlined, DeleteOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
import { CloudUploadOutlined, DeleteOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
|
batchDeleteDomains,
|
||||||
deleteDomain,
|
deleteDomain,
|
||||||
listDomainAccounts,
|
listDomainAccounts,
|
||||||
listDomains,
|
listDomains,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
type DomainRow,
|
type DomainRow,
|
||||||
} from "../api.ts";
|
} from "../api.ts";
|
||||||
import { WebhookForm } from "../components/WebhookForm.tsx";
|
import { WebhookForm } from "../components/WebhookForm.tsx";
|
||||||
|
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||||
|
|
||||||
/** # 成员变量: 域名状态 -> Tag **/
|
/** # 成员变量: 域名状态 -> Tag **/
|
||||||
function StatusTag({ row }: { row: DomainRow }) {
|
function StatusTag({ row }: { row: DomainRow }) {
|
||||||
@@ -38,6 +40,8 @@ export function DomainsPage() {
|
|||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [accountId, setAccountId] = useState<string | undefined>();
|
const [accountId, setAccountId] = useState<string | undefined>();
|
||||||
const [editing, setEditing] = useState<DomainRow | null>(null);
|
const [editing, setEditing] = useState<DomainRow | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -98,7 +102,29 @@ export function DomainsPage() {
|
|||||||
/>
|
/>
|
||||||
<span className="muted">域名在 Cloudflare OAuth 授权后自动同步;点击域名行设置 Email Workers 与 Webhook。</span>
|
<span className="muted">域名在 Cloudflare OAuth 授权后自动同步;点击域名行设置 Email Workers 与 Webhook。</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Space>
|
||||||
|
<BatchActions
|
||||||
|
count={count}
|
||||||
|
onClear={clear}
|
||||||
|
loading={deleting}
|
||||||
|
title={`删除选中的 ${count} 个域名?`}
|
||||||
|
description="这些域名下的所有邮箱(含 catch-all)及其邮件会一并删除,不可恢复。"
|
||||||
|
onDelete={async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await batchDeleteDomains(selectedIds);
|
||||||
|
message.success(`已删除 ${res.deleted} 个域名、${res.mailboxes ?? 0} 个邮箱、${res.emails ?? 0} 封邮件`);
|
||||||
|
clear();
|
||||||
|
void load();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "批量删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card styles={{ body: { padding: 0 } }}>
|
<Card styles={{ body: { padding: 0 } }}>
|
||||||
@@ -106,8 +132,16 @@ export function DomainsPage() {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={filtered}
|
dataSource={filtered}
|
||||||
|
rowSelection={rowSelection}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
onRow={(row) => ({ onClick: () => setEditing(row), style: { cursor: "pointer" } })}
|
onRow={(row) => ({
|
||||||
|
onClick: (e) => {
|
||||||
|
// 点击复选框不打开详情抽屉
|
||||||
|
if ((e.target as HTMLElement).closest(".ant-table-selection-cell")) return;
|
||||||
|
setEditing(row);
|
||||||
|
},
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
})}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
|
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
|
||||||
{ title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
{ title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||||
|
|||||||
@@ -22,7 +22,16 @@ import {
|
|||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { deleteEmail, getEmail, listDomains, listEmails, type EmailDetail, type EmailRow } from "../api.ts";
|
import {
|
||||||
|
batchDeleteEmails,
|
||||||
|
deleteEmail,
|
||||||
|
getEmail,
|
||||||
|
listDomains,
|
||||||
|
listEmails,
|
||||||
|
type EmailDetail,
|
||||||
|
type EmailRow,
|
||||||
|
} from "../api.ts";
|
||||||
|
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 字节数格式化
|
* 字节数格式化
|
||||||
@@ -79,6 +88,8 @@ export function InboxPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [detail, setDetail] = useState<EmailDetail | null>(null);
|
const [detail, setDetail] = useState<EmailDetail | null>(null);
|
||||||
const [htmlPreview, setHtmlPreview] = useState(false);
|
const [htmlPreview, setHtmlPreview] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -133,16 +144,42 @@ export function InboxPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<BatchActions
|
||||||
|
count={count}
|
||||||
|
onClear={clear}
|
||||||
|
loading={deleting}
|
||||||
|
title={`删除选中的 ${count} 封邮件?`}
|
||||||
|
description="邮件原文与附件会从 R2 一并删除,不可恢复。"
|
||||||
|
onDelete={async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await batchDeleteEmails(selectedIds);
|
||||||
|
message.success(`已删除 ${res.deleted} 封邮件`);
|
||||||
|
clear();
|
||||||
|
setDetail(null);
|
||||||
|
void load();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "批量删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Card styles={{ body: { padding: 0 } }}>
|
<Card styles={{ body: { padding: 0 } }}>
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
|
rowSelection={rowSelection}
|
||||||
rowClassName={(row) => (row.expires_at <= new Date().toISOString() ? "muted" : "")}
|
rowClassName={(row) => (row.expires_at <= new Date().toISOString() ? "muted" : "")}
|
||||||
onRow={(row) => ({
|
onRow={(row) => ({
|
||||||
onClick: async () => {
|
onClick: async (e) => {
|
||||||
|
// 点击复选框不打开邮件详情
|
||||||
|
if ((e.target as HTMLElement).closest(".ant-table-selection-cell")) return;
|
||||||
setDetail(await getEmail(row.id));
|
setDetail(await getEmail(row.id));
|
||||||
setHtmlPreview(false);
|
setHtmlPreview(false);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
type MailboxRow,
|
type MailboxRow,
|
||||||
} from "../api.ts";
|
} from "../api.ts";
|
||||||
import { WebhookForm } from "../components/WebhookForm.tsx";
|
import { WebhookForm } from "../components/WebhookForm.tsx";
|
||||||
|
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||||
|
import { batchDeleteMailboxes } from "../api.ts";
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 邮箱页组件
|
* 邮箱页组件
|
||||||
@@ -25,6 +27,8 @@ export function MailboxesPage() {
|
|||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [webhookTarget, setWebhookTarget] = useState<MailboxRow | null>(null);
|
const [webhookTarget, setWebhookTarget] = useState<MailboxRow | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -68,6 +72,26 @@ export function MailboxesPage() {
|
|||||||
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
|
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
|
<BatchActions
|
||||||
|
count={count}
|
||||||
|
onClear={clear}
|
||||||
|
loading={deleting}
|
||||||
|
title={`删除选中的 ${count} 个邮箱?`}
|
||||||
|
description="邮箱下的邮件(含 R2 原文与附件)会一并删除,且不可恢复。"
|
||||||
|
onDelete={async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await batchDeleteMailboxes(selectedIds);
|
||||||
|
message.success(`已删除 ${res.deleted} 个邮箱、${res.emails ?? 0} 封邮件`);
|
||||||
|
clear();
|
||||||
|
void load();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "批量删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
|
||||||
添加邮箱
|
添加邮箱
|
||||||
@@ -79,6 +103,7 @@ export function MailboxesPage() {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={filtered}
|
dataSource={filtered}
|
||||||
|
rowSelection={rowSelection}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
|
|||||||
+20
-14
@@ -18,7 +18,13 @@ import { Hono } from "hono";
|
|||||||
import type { Env, AppVars } from "../env.ts";
|
import type { Env, AppVars } from "../env.ts";
|
||||||
import { requireSession } from "../middleware.ts";
|
import { requireSession } from "../middleware.ts";
|
||||||
import { ids } from "../../../shared/src/index.ts";
|
import { ids } from "../../../shared/src/index.ts";
|
||||||
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi, isOAuthConfigured } from "../services/cfapi.ts";
|
import {
|
||||||
|
CF_OAUTH_AUTHORIZE_URL,
|
||||||
|
CF_OAUTH_SCOPES,
|
||||||
|
getCFApi,
|
||||||
|
getOAuthClient,
|
||||||
|
oauthRedirectUri,
|
||||||
|
} from "../services/cfapi.ts";
|
||||||
import { provisionAccount, createAccount } from "../services/provision.ts";
|
import { provisionAccount, createAccount } from "../services/provision.ts";
|
||||||
import { encryptConnectionToken } from "../db.ts";
|
import { encryptConnectionToken } from "../db.ts";
|
||||||
import { nowIso } from "../util.ts";
|
import { nowIso } from "../util.ts";
|
||||||
@@ -42,18 +48,20 @@ export function oauthRoutes(): App {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */
|
/** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */
|
||||||
app.get("/mode", (c) => {
|
app.get("/mode", async (c) => {
|
||||||
const configured = isOAuthConfigured(c.env);
|
const client = await getOAuthClient(c.env);
|
||||||
return c.json({
|
return c.json({
|
||||||
mode: configured ? "cloudflare" : "unconfigured",
|
mode: client ? "cloudflare" : "unconfigured",
|
||||||
authorize_url_base: configured ? CF_OAUTH_AUTHORIZE_URL : null,
|
authorize_url_base: client ? CF_OAUTH_AUTHORIZE_URL : null,
|
||||||
|
source: client?.source ?? "none",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */
|
/** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */
|
||||||
app.get("/cloudflare/start", async (c) => {
|
app.get("/cloudflare/start", async (c) => {
|
||||||
if (!isOAuthConfigured(c.env)) {
|
const client = await getOAuthClient(c.env);
|
||||||
return c.json({ error: "cloudflare oauth not configured (CF_OAUTH_CLIENT_ID/CF_OAUTH_CLIENT_SECRET)" }, 503);
|
if (!client) {
|
||||||
|
return c.json({ error: "cloudflare oauth not configured (在设置 → Cloudflare 接入 中填写 Client ID / Secret)" }, 503);
|
||||||
}
|
}
|
||||||
const created = await createAccount(c.env, {
|
const created = await createAccount(c.env, {
|
||||||
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
|
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
|
||||||
@@ -66,11 +74,10 @@ export function oauthRoutes(): App {
|
|||||||
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
|
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
const base = c.env.PUBLIC_BASE_URL ?? new URL(c.req.url).origin;
|
const redirectUri = oauthRedirectUri(c.env, new URL(c.req.url).origin);
|
||||||
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
|
|
||||||
const url =
|
const url =
|
||||||
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
|
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
|
||||||
`&client_id=${encodeURIComponent(c.env.CF_OAUTH_CLIENT_ID!)}` +
|
`&client_id=${encodeURIComponent(client.clientId)}` +
|
||||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
|
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
|
||||||
`&state=${encodeURIComponent(state)}`;
|
`&state=${encodeURIComponent(state)}`;
|
||||||
@@ -79,7 +86,7 @@ export function oauthRoutes(): App {
|
|||||||
|
|
||||||
/** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */
|
/** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */
|
||||||
app.get("/cloudflare/callback", async (c) => {
|
app.get("/cloudflare/callback", async (c) => {
|
||||||
if (!isOAuthConfigured(c.env)) {
|
if (!(await getOAuthClient(c.env))) {
|
||||||
return c.text("Cloudflare OAuth not configured", 503);
|
return c.text("Cloudflare OAuth not configured", 503);
|
||||||
}
|
}
|
||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
@@ -89,10 +96,9 @@ export function oauthRoutes(): App {
|
|||||||
if (!verdict) {
|
if (!verdict) {
|
||||||
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
|
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
|
||||||
}
|
}
|
||||||
const cf = getCFApi(c.env);
|
const cf = await getCFApi(c.env);
|
||||||
try {
|
try {
|
||||||
const base = c.env.PUBLIC_BASE_URL ?? url.origin;
|
const redirectUri = oauthRedirectUri(c.env, url.origin);
|
||||||
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
|
|
||||||
const token = await cf.exchangeToken(code, redirectUri);
|
const token = await cf.exchangeToken(code, redirectUri);
|
||||||
// 加密入库(不进前端/日志)
|
// 加密入库(不进前端/日志)
|
||||||
await saveConnection(c.env, token.accessToken, token.expiresIn);
|
await saveConnection(c.env, token.accessToken, token.expiresIn);
|
||||||
|
|||||||
+148
-20
@@ -6,6 +6,9 @@
|
|||||||
* - POST /domains/:id/setup 为域名配置 Email Workers(Worker + Routing + catch-all)
|
* - POST /domains/:id/setup 为域名配置 Email Workers(Worker + Routing + catch-all)
|
||||||
* - POST /accounts/:id/rotate 轮换账号的 Worker credential(secret 仅一次可见)
|
* - POST /accounts/:id/rotate 轮换账号的 Worker credential(secret 仅一次可见)
|
||||||
* - 邮箱/域名级 Webhook 配置(PUT/DELETE .../webhook)
|
* - 邮箱/域名级 Webhook 配置(PUT/DELETE .../webhook)
|
||||||
|
* - 批量操作(POST .../batch-delete):四个资源列表的复选框批量删除
|
||||||
|
* 账号=软删(撤销 Worker 凭据并标记 revoked);域名/邮箱=级联删除其下邮件;
|
||||||
|
* 邮件=连同 R2 原文与附件一起删除
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
@@ -56,6 +59,80 @@ async function parseWebhookBody(
|
|||||||
return { url, headers, key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey };
|
return { url, headers, key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** # 成员变量: 批量操作请求体 **/
|
||||||
|
interface BatchBody {
|
||||||
|
ids?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量: 批量删除单次 id 上限(避免一次请求打爆 Worker) **/
|
||||||
|
const BATCH_LIMIT = 500;
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 解析批量操作请求体中的 id 列表(过滤非字符串、去重、限长)
|
||||||
|
*/
|
||||||
|
async function readIds(c: { req: { raw: Request } }): Promise<string[]> {
|
||||||
|
const body = await readJson<BatchBody>(c.req.raw);
|
||||||
|
const list = Array.isArray(body.ids) ? body.ids : [];
|
||||||
|
return [...new Set(list.filter((v): v is string => typeof v === "string" && v.length > 0))].slice(0, BATCH_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 删除一封邮件:R2 原文与附件 -> 附件行 -> webhook delivery 行 -> 邮件行
|
||||||
|
* 返回值: 该邮件存在并被删除时 true
|
||||||
|
*/
|
||||||
|
async function deleteEmailById(env: Env, id: string): Promise<boolean> {
|
||||||
|
const email = await env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.first<{ raw_storage_key: string }>();
|
||||||
|
if (!email) return false;
|
||||||
|
const atts = await env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.all<{ storage_key: string | null }>();
|
||||||
|
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(
|
||||||
|
(k): k is string => Boolean(k),
|
||||||
|
);
|
||||||
|
if (keys.length) await env.R2.delete(keys);
|
||||||
|
await env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(id).run();
|
||||||
|
await env.DB.prepare("DELETE FROM webhook_deliveries WHERE email_id = ?").bind(id).run();
|
||||||
|
await env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(id).run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 删除某邮箱下的全部邮件(批量删除邮箱/域名时级联调用)
|
||||||
|
* 返回值: 删除的邮件数量
|
||||||
|
*/
|
||||||
|
async function deleteEmailsByMailbox(env: Env, mailboxId: string): Promise<number> {
|
||||||
|
const emails = await env.DB.prepare("SELECT id FROM emails WHERE mailbox_id = ?")
|
||||||
|
.bind(mailboxId)
|
||||||
|
.all<{ id: string }>();
|
||||||
|
let n = 0;
|
||||||
|
for (const e of emails.results) {
|
||||||
|
if (await deleteEmailById(env, e.id)) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 软删账号:撤销 Worker credential + 标记 revoked(与 DELETE /accounts/:id 同一语义)
|
||||||
|
* 返回值: 账号存在且未撤销时 true
|
||||||
|
*/
|
||||||
|
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'")
|
||||||
|
.bind(id)
|
||||||
|
.first<{ id: string }>();
|
||||||
|
if (!row) return false;
|
||||||
|
await env.DB.prepare(
|
||||||
|
"UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'",
|
||||||
|
)
|
||||||
|
.bind(nowIso(), id)
|
||||||
|
.run();
|
||||||
|
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?")
|
||||||
|
.bind(nowIso(), id)
|
||||||
|
.run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* Account(账号)管理与安装状态
|
* Account(账号)管理与安装状态
|
||||||
*/
|
*/
|
||||||
@@ -117,17 +194,22 @@ function installAccountRoutes(app: App) {
|
|||||||
/** 删除账号(软删:撤销 Worker credential + 标记 revoked) */
|
/** 删除账号(软删:撤销 Worker credential + 标记 revoked) */
|
||||||
app.delete("/accounts/:id", async (c) => {
|
app.delete("/accounts/:id", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
await c.env.DB.prepare(
|
await revokeAccount(c.env, id);
|
||||||
"UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'",
|
|
||||||
)
|
|
||||||
.bind(nowIso(), id)
|
|
||||||
.run();
|
|
||||||
await c.env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?")
|
|
||||||
.bind(nowIso(), id)
|
|
||||||
.run();
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 批量删除账号(软删 + 撤销 Worker credential);域名数据保留 */
|
||||||
|
app.post("/accounts/batch-delete", async (c) => {
|
||||||
|
const ids = await readIds(c);
|
||||||
|
const failed: { id: string; reason: string }[] = [];
|
||||||
|
let deleted = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
if (await revokeAccount(c.env, id)) deleted++;
|
||||||
|
else failed.push({ id, reason: "账号不存在或已删除" });
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, deleted, failed });
|
||||||
|
});
|
||||||
|
|
||||||
/** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见);
|
/** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见);
|
||||||
* 已配置 Email Workers 的域名自动重新部署脚本以携带新 secret。 */
|
* 已配置 Email Workers 的域名自动重新部署脚本以携带新 secret。 */
|
||||||
app.post("/accounts/:id/rotate", async (c) => {
|
app.post("/accounts/:id/rotate", async (c) => {
|
||||||
@@ -247,6 +329,35 @@ function installDomainRoutes(app: App) {
|
|||||||
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
|
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 批量删除域名:级联删除该域名下所有邮箱(含 catch-all)及其邮件
|
||||||
|
* ——单条删除为了防误操作会在有邮箱时返回 409,批量场景下 catch-all 邮箱总是存在,
|
||||||
|
* 因此批量入口显式级联,前端确认框会写明影响范围。 */
|
||||||
|
app.post("/domains/batch-delete", async (c) => {
|
||||||
|
const ids = await readIds(c);
|
||||||
|
const failed: { id: string; reason: string }[] = [];
|
||||||
|
let deleted = 0;
|
||||||
|
let mailboxes = 0;
|
||||||
|
let emails = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
const boxes = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE domain_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.all<{ id: string }>();
|
||||||
|
for (const b of boxes.results) {
|
||||||
|
emails += await deleteEmailsByMailbox(c.env, b.id);
|
||||||
|
}
|
||||||
|
await c.env.DB.prepare("DELETE FROM mailboxes WHERE domain_id = ?").bind(id).run();
|
||||||
|
const res = await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
|
||||||
|
const changes = (res as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0;
|
||||||
|
if (changes > 0) {
|
||||||
|
deleted++;
|
||||||
|
mailboxes += boxes.results.length;
|
||||||
|
} else {
|
||||||
|
failed.push({ id, reason: "域名不存在" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, deleted, mailboxes, emails, failed });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
@@ -338,6 +449,22 @@ function installMailboxRoutes(app: App) {
|
|||||||
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
|
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 批量删除邮箱:级联删除邮箱下的邮件(R2 原文与附件一并清理) */
|
||||||
|
app.post("/mailboxes/batch-delete", async (c) => {
|
||||||
|
const ids = await readIds(c);
|
||||||
|
const failed: { id: string; reason: string }[] = [];
|
||||||
|
let deleted = 0;
|
||||||
|
let emails = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
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: "邮箱不存在" });
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, deleted, emails, failed });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
@@ -432,18 +559,19 @@ function installEmailRoutes(app: App) {
|
|||||||
|
|
||||||
app.delete("/emails/:id", async (c) => {
|
app.delete("/emails/:id", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
|
if (!(await deleteEmailById(c.env, id))) return c.json({ error: "not found" }, 404);
|
||||||
.bind(id)
|
|
||||||
.first<{ raw_storage_key: string }>();
|
|
||||||
if (!email) return c.json({ error: "not found" }, 404);
|
|
||||||
const atts = await c.env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
|
|
||||||
.bind(id)
|
|
||||||
.all<{ storage_key: string | null }>();
|
|
||||||
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(Boolean) as string[];
|
|
||||||
if (keys.length) await c.env.R2.delete(keys);
|
|
||||||
await c.env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(id).run();
|
|
||||||
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE email_id = ?").bind(id).run();
|
|
||||||
await c.env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(id).run();
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 批量删除邮件:R2 原文与附件 + 附件行 + delivery 行一并清理 */
|
||||||
|
app.post("/emails/batch-delete", async (c) => {
|
||||||
|
const ids = await readIds(c);
|
||||||
|
const failed: { id: string; reason: string }[] = [];
|
||||||
|
let deleted = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
if (await deleteEmailById(c.env, id)) deleted++;
|
||||||
|
else failed.push({ id, reason: "邮件不存在或已过期清理" });
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, deleted, failed });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 设置路由(v2.1.0):
|
* 设置路由(v2.3.0):
|
||||||
* GET /api/settings 读取:全局 Webhook / API Key 元信息 / 当前用户
|
* GET /api/settings 读取:全局 Webhook / API Key 元信息 / Cloudflare OAuth 客户端 / 当前用户
|
||||||
* PUT /api/settings 保存:全局 Webhook
|
* PUT /api/settings 保存:全局 Webhook、Cloudflare OAuth 客户端
|
||||||
* POST /api/settings/api-key 生成(轮换)单一 API/MCP Key,明文仅返回一次
|
* POST /api/settings/api-key 生成(轮换)单一 API/MCP Key,明文仅返回一次
|
||||||
* DEL /api/settings/api-key 撤销并删除 API Key
|
* DEL /api/settings/api-key 撤销并删除 API Key
|
||||||
|
* DEL /api/settings/cf-oauth 清除后台保存的 Cloudflare OAuth 客户端凭据
|
||||||
* PUT /api/settings/password 修改登录密码
|
* PUT /api/settings/password 修改登录密码
|
||||||
* API 与 MCP 共用同一 Key,凭据存 settings 表(不再单独建表)。
|
* API 与 MCP 共用同一 Key,凭据存 settings 表(不再单独建表);
|
||||||
|
* Cloudflare OAuth Client Secret 同样只存加密密文,接口只回 last4。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
@@ -19,6 +21,13 @@ import {
|
|||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from "../../../shared/src/index.ts";
|
} from "../../../shared/src/index.ts";
|
||||||
import { getSetting, keyHash, setSetting } from "../db.ts";
|
import { getSetting, keyHash, setSetting } from "../db.ts";
|
||||||
|
import {
|
||||||
|
CF_OAUTH_SETTING_KEYS,
|
||||||
|
clearOAuthClient,
|
||||||
|
oauthClientMeta,
|
||||||
|
oauthRedirectUri,
|
||||||
|
saveOAuthClient,
|
||||||
|
} from "../services/cfapi.ts";
|
||||||
import {
|
import {
|
||||||
getGlobalWebhookKey,
|
getGlobalWebhookKey,
|
||||||
normalizeWebhookHeaders,
|
normalizeWebhookHeaders,
|
||||||
@@ -70,13 +79,17 @@ export function settingsRoutes(): App {
|
|||||||
username: user?.username ?? "",
|
username: user?.username ?? "",
|
||||||
webhook,
|
webhook,
|
||||||
api_key: await apiKeyMeta(c.env),
|
api_key: await apiKeyMeta(c.env),
|
||||||
|
cf_oauth: await oauthClientMeta(c.env),
|
||||||
|
// 供前端展示:需要在 Cloudflare Dashboard 登记的回调地址
|
||||||
|
redirect_uri: oauthRedirectUri(c.env, new URL(c.req.url).origin),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 保存设置:全局 Webhook */
|
/** 保存设置:全局 Webhook、Cloudflare OAuth 客户端 */
|
||||||
app.put("/", async (c) => {
|
app.put("/", async (c) => {
|
||||||
const body = await readJson<{
|
const body = await readJson<{
|
||||||
webhook?: { url?: string; headers?: unknown; key?: string };
|
webhook?: { url?: string; headers?: unknown; key?: string };
|
||||||
|
cf_oauth?: { client_id?: string; secret?: string };
|
||||||
}>(c.req.raw);
|
}>(c.req.raw);
|
||||||
|
|
||||||
if (body.webhook) {
|
if (body.webhook) {
|
||||||
@@ -100,6 +113,23 @@ export function settingsRoutes(): App {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.cf_oauth) {
|
||||||
|
const clientId = (body.cf_oauth.client_id ?? "").trim();
|
||||||
|
// secret:undefined 保留现有;空白串(含 "")清除;非空设置新 secret
|
||||||
|
const rawSecret = typeof body.cf_oauth.secret === "string" ? body.cf_oauth.secret : undefined;
|
||||||
|
if (rawSecret === undefined) {
|
||||||
|
// 只改 client id:必须已存在 secret,否则保存后仍是未配置状态
|
||||||
|
const current = await getSetting(c.env, CF_OAUTH_SETTING_KEYS.secretEnc);
|
||||||
|
if (!current) return c.json({ error: "请填写 Client Secret" }, 400);
|
||||||
|
if (clientId) await setSetting(c.env, CF_OAUTH_SETTING_KEYS.clientId, clientId);
|
||||||
|
} else if (rawSecret.trim() === "") {
|
||||||
|
await clearOAuthClient(c.env);
|
||||||
|
} else {
|
||||||
|
if (!clientId) return c.json({ error: "请填写 Client ID" }, 400);
|
||||||
|
await saveOAuthClient(c.env, clientId, rawSecret.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,6 +152,12 @@ export function settingsRoutes(): App {
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 清除后台保存的 Cloudflare OAuth 客户端凭据(env 兜底仍可用) */
|
||||||
|
app.delete("/cf-oauth", async (c) => {
|
||||||
|
await clearOAuthClient(c.env);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
/** 修改登录密码 */
|
/** 修改登录密码 */
|
||||||
app.put("/password", async (c) => {
|
app.put("/password", async (c) => {
|
||||||
const body = await readJson<{ old_password?: string; new_password?: string }>(c.req.raw);
|
const body = await readJson<{ old_password?: string; new_password?: string }>(c.req.raw);
|
||||||
|
|||||||
+110
-16
@@ -3,12 +3,17 @@
|
|||||||
*
|
*
|
||||||
* OAuth access token 来自 Cloudflare OAuth 授权(docs/02),Console 用它完成
|
* OAuth access token 来自 Cloudflare OAuth 授权(docs/02),Console 用它完成
|
||||||
* 账号/域名发现、Email Worker 部署与 Email Routing 配置。
|
* 账号/域名发现、Email Worker 部署与 Email Routing 配置。
|
||||||
* 未配置 CF_OAUTH_CLIENT_ID/SECRET 时不提供任何降级通道——
|
*
|
||||||
* /api/oauth/cloudflare/start 直接返回 503,避免出现"看似接入成功"的假数据。
|
* OAuth 客户端凭据(client id / secret)有两个来源,settings 表优先、env 兜底:
|
||||||
|
* 1. settings 表 —— 在「设置 → Cloudflare 接入」里填写;secret 用 AES-GCM 加密存储
|
||||||
|
* (cf_oauth_client_secret_enc),只回 last4。保存后立即生效,无需重新部署。
|
||||||
|
* 2. env 变量 CF_OAUTH_CLIENT_ID / CF_OAUTH_CLIENT_SECRET —— 部署时注入的兜底。
|
||||||
|
* 两者都没有时不提供任何降级通道,接入接口直接返回 503,避免出现"看似接入成功"的假数据。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
|
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
|
||||||
import type { Env } from "../env.ts";
|
import type { Env } from "../env.ts";
|
||||||
|
import { decryptSecret, encryptSecret, getSetting, setSetting } from "../db.ts";
|
||||||
|
|
||||||
/** # 成员变量: OAuth 授权端点 **/
|
/** # 成员变量: OAuth 授权端点 **/
|
||||||
export const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
|
export const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
|
||||||
@@ -19,6 +24,99 @@ export const CF_OAUTH_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
|
|||||||
/** # 成员变量: Cloudflare API 基址 **/
|
/** # 成员变量: Cloudflare API 基址 **/
|
||||||
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||||
|
|
||||||
|
/** # 成员变量: 授权回调路径 **/
|
||||||
|
export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
|
||||||
|
|
||||||
|
/** # 成员变量: settings 表中保存 OAuth 客户端凭据的 key **/
|
||||||
|
export const CF_OAUTH_SETTING_KEYS = {
|
||||||
|
clientId: "cf_oauth_client_id",
|
||||||
|
secretEnc: "cf_oauth_client_secret_enc",
|
||||||
|
secretLast4: "cf_oauth_client_secret_last4",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** # 成员变量: 已解析的 Cloudflare OAuth 客户端凭据 **/
|
||||||
|
export interface CFOAuthClient {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
/** 凭据来源:settings(后台设置)优先,env(部署变量)兜底 */
|
||||||
|
source: "settings" | "env";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 解析当前生效的 Cloudflare OAuth 客户端凭据(settings 优先,env 兜底)
|
||||||
|
* 返回值: 未配置时 null
|
||||||
|
*/
|
||||||
|
export async function getOAuthClient(env: Env): Promise<CFOAuthClient | null> {
|
||||||
|
const [clientId, secretEnc] = await Promise.all([
|
||||||
|
getSetting(env, CF_OAUTH_SETTING_KEYS.clientId),
|
||||||
|
getSetting(env, CF_OAUTH_SETTING_KEYS.secretEnc),
|
||||||
|
]);
|
||||||
|
if (clientId && secretEnc) {
|
||||||
|
try {
|
||||||
|
const secret = await decryptSecret(env, secretEnc);
|
||||||
|
if (secret) return { clientId, clientSecret: secret, source: "settings" };
|
||||||
|
} catch {
|
||||||
|
// 密文损坏(主密钥变更等)时继续尝试 env 兜底
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET) {
|
||||||
|
return { clientId: env.CF_OAUTH_CLIENT_ID, clientSecret: env.CF_OAUTH_CLIENT_SECRET, source: "env" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* Cloudflare OAuth 是否已配置(settings 或 env 任一来源可用)
|
||||||
|
*/
|
||||||
|
export async function isOAuthConfigured(env: Env): Promise<boolean> {
|
||||||
|
return (await getOAuthClient(env)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* OAuth 客户端凭据的展示用元信息(不含明文 secret)
|
||||||
|
*/
|
||||||
|
export async function oauthClientMeta(
|
||||||
|
env: Env,
|
||||||
|
): Promise<{ client_id: string; has_secret: boolean; secret_last4: string; source: "settings" | "env" | "none" }> {
|
||||||
|
const client = await getOAuthClient(env);
|
||||||
|
if (!client) return { client_id: "", has_secret: false, secret_last4: "", source: "none" };
|
||||||
|
const last4 = await getSetting(env, CF_OAUTH_SETTING_KEYS.secretLast4);
|
||||||
|
return {
|
||||||
|
client_id: client.clientId,
|
||||||
|
has_secret: true,
|
||||||
|
secret_last4: client.source === "settings" ? (last4 ?? "") : client.clientSecret.slice(-4),
|
||||||
|
source: client.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 保存 OAuth 客户端凭据到 settings(secret 加密存储,只留 last4 供展示)
|
||||||
|
*/
|
||||||
|
export async function saveOAuthClient(env: Env, clientId: string, secret: string): Promise<void> {
|
||||||
|
await setSetting(env, CF_OAUTH_SETTING_KEYS.clientId, clientId);
|
||||||
|
await setSetting(env, CF_OAUTH_SETTING_KEYS.secretEnc, await encryptSecret(env, secret));
|
||||||
|
await setSetting(env, CF_OAUTH_SETTING_KEYS.secretLast4, secret.slice(-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 清除 settings 中的 OAuth 客户端凭据(env 兜底不受影响)
|
||||||
|
*/
|
||||||
|
export async function clearOAuthClient(env: Env): Promise<void> {
|
||||||
|
for (const key of Object.values(CF_OAUTH_SETTING_KEYS)) {
|
||||||
|
await env.DB.prepare("DELETE FROM settings WHERE key = ?").bind(key).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 计算 OAuth 授权回调地址(优先 CF_OAUTH_REDIRECT_URI,其次 PUBLIC_BASE_URL,最后请求 origin)
|
||||||
|
*/
|
||||||
|
export function oauthRedirectUri(env: Env, requestOrigin: string): string {
|
||||||
|
const base = env.CF_OAUTH_REDIRECT_URI ?? env.PUBLIC_BASE_URL ?? requestOrigin;
|
||||||
|
return base.endsWith(CF_OAUTH_CALLBACK_PATH)
|
||||||
|
? base
|
||||||
|
: `${base.replace(/\/$/, "")}${CF_OAUTH_CALLBACK_PATH}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
|
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
|
||||||
export const CF_OAUTH_SCOPES = [
|
export const CF_OAUTH_SCOPES = [
|
||||||
"account:read",
|
"account:read",
|
||||||
@@ -71,23 +169,19 @@ export interface CFApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* Cloudflare OAuth 是否已配置(client id + secret 齐备)
|
* 构造 Cloudflare API 客户端(内部使用已解析的 OAuth 客户端凭据)
|
||||||
|
* 返回值: 未配置 OAuth 客户端时抛错(调用方应先判断 isOAuthConfigured)
|
||||||
*/
|
*/
|
||||||
export function isOAuthConfigured(env: Env): boolean {
|
export async function getCFApi(env: Env): Promise<CFApi> {
|
||||||
return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET);
|
const client = await getOAuthClient(env);
|
||||||
}
|
if (!client) throw new Error("cloudflare oauth not configured");
|
||||||
|
return new RealCFApi(client);
|
||||||
/** # 函数
|
|
||||||
* 构造 Cloudflare API 客户端
|
|
||||||
*/
|
|
||||||
export function getCFApi(env: Env): CFApi {
|
|
||||||
return new RealCFApi(env);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 成员变量: Cloudflare API 实现 **/
|
/** # 成员变量: Cloudflare API 实现 **/
|
||||||
export class RealCFApi implements CFApi {
|
export class RealCFApi implements CFApi {
|
||||||
/** # 成员变量 **/
|
/** # 成员变量: OAuth 客户端凭据(settings 或 env) **/
|
||||||
constructor(private env: Env) {}
|
constructor(private client: CFOAuthClient) {}
|
||||||
|
|
||||||
async exchangeToken(code: string, redirectUri: string) {
|
async exchangeToken(code: string, redirectUri: string) {
|
||||||
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
|
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
|
||||||
@@ -96,8 +190,8 @@ export class RealCFApi implements CFApi {
|
|||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code,
|
code,
|
||||||
client_id: this.env.CF_OAUTH_CLIENT_ID ?? "",
|
client_id: this.client.clientId,
|
||||||
client_secret: this.env.CF_OAUTH_CLIENT_SECRET ?? "",
|
client_secret: this.client.clientSecret,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export interface ProvisionInput {
|
|||||||
* 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。
|
* 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。
|
||||||
*/
|
*/
|
||||||
export async function provisionAccount(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
|
export async function provisionAccount(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
|
||||||
const cf: CFApi = getCFApi(env);
|
const cf: CFApi = await getCFApi(env);
|
||||||
const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?")
|
const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?")
|
||||||
.bind(input.accountId)
|
.bind(input.accountId)
|
||||||
.first<{ id: string; name: string; status: string }>();
|
.first<{ id: string; name: string; status: string }>();
|
||||||
@@ -249,7 +249,7 @@ async function putWorkerScript(
|
|||||||
workerId: string,
|
workerId: string,
|
||||||
workerSecret: string,
|
workerSecret: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cf: CFApi = getCFApi(env);
|
const cf: CFApi = await getCFApi(env);
|
||||||
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
|
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
|
||||||
if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)");
|
if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)");
|
||||||
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret });
|
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret });
|
||||||
@@ -278,7 +278,7 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
|
|||||||
const accessToken = await decryptConnectionToken(env);
|
const accessToken = await decryptConnectionToken(env);
|
||||||
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
|
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
|
||||||
|
|
||||||
const cf: CFApi = getCFApi(env);
|
const cf: CFApi = await getCFApi(env);
|
||||||
try {
|
try {
|
||||||
const workerId = await deployAccountWorker(env, account.id, accessToken);
|
const workerId = await deployAccountWorker(env, account.id, accessToken);
|
||||||
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
||||||
|
|||||||
@@ -34,8 +34,16 @@ Console
|
|||||||
- 安装过程幂等,重复执行不产生重复资源。
|
- 安装过程幂等,重复执行不产生重复资源。
|
||||||
- Worker credential 与 Cloudflare credential 分离;轮换 credential 后已配置域名自动重新部署。
|
- Worker credential 与 Cloudflare credential 分离;轮换 credential 后已配置域名自动重新部署。
|
||||||
|
|
||||||
## 前置条件
|
## 前置条件:OAuth 客户端凭据
|
||||||
|
|
||||||
`CF_OAUTH_CLIENT_ID` 与 `CF_OAUTH_CLIENT_SECRET` 必须配置,否则 `/api/oauth/cloudflare/start` 与
|
授权所需的 Client ID / Secret 有两个来源,**settings 表优先、env 兜底**:
|
||||||
`/api/oauth/cloudflare/callback` 一律返回 503 —— 系统不提供任何模拟/降级通道,
|
|
||||||
不会出现"看起来接入成功"的假账号与假资源。
|
| 来源 | 配置位置 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| settings(优先) | Console「设置 → Cloudflare 接入」 | Secret 以 AES-GCM 密文存 `settings.cf_oauth_client_secret_enc`,只回显末 4 位;保存后立即生效,无需重新部署。清除走 `DELETE /api/settings/cf-oauth` |
|
||||||
|
| env(兜底) | `CF_OAUTH_CLIENT_ID` / `CF_OAUTH_CLIENT_SECRET` | 部署时注入;settings 未配置或密文损坏时使用 |
|
||||||
|
|
||||||
|
两个来源都没有时,`/api/oauth/cloudflare/start` 与 `/api/oauth/cloudflare/callback` 一律返回 503
|
||||||
|
—— 系统不提供任何模拟/降级通道,不会出现"看起来接入成功"的假账号与假资源。
|
||||||
|
`GET /api/settings` 会返回 `cf_oauth.source`(settings / env / none)与 `redirect_uri`(需在
|
||||||
|
Cloudflare Dashboard 登记的回调地址),`GET /api/oauth/mode` 返回 `source` 供账号页判断。
|
||||||
|
|||||||
@@ -79,4 +79,6 @@ status(active | revoked), created_at, revoked_at
|
|||||||
|
|
||||||
## 其它
|
## 其它
|
||||||
|
|
||||||
users、settings(全局 Webhook、单一 API/MCP Key:api_key_hash/api_key_last4/api_key_created_at)、nonces、oauth_states。v2.1.0 起 api_credentials 表已删除(Key 合并为单一 Key 存 settings);v2.2.0 起 cf_sim_resources 表已删除(移除本地 sim 模式,接入链路只有真实 Cloudflare OAuth)。
|
users、settings(全局 Webhook、单一 API/MCP Key:api_key_hash/api_key_last4/api_key_created_at、
|
||||||
|
Cloudflare OAuth 客户端:cf_oauth_client_id / cf_oauth_client_secret_enc(AES-GCM 密文) / cf_oauth_client_secret_last4)、
|
||||||
|
nonces、oauth_states。v2.1.0 起 api_credentials 表已删除(Key 合并为单一 Key 存 settings);v2.2.0 起 cf_sim_resources 表已删除(移除本地 sim 模式,接入链路只有真实 Cloudflare OAuth)。
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/** 批量操作测试(列表复选框):四个资源的批量删除与子资源级联 */
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { authedFetch, loginCookie, makeTestEnv, signedIngestRequest, TEST_MAIL } from "./helpers.ts";
|
||||||
|
import { nowIso } from "../console/src/util.ts";
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 调批量删除接口
|
||||||
|
*/
|
||||||
|
async function batchDelete(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string, path: string, ids: unknown) {
|
||||||
|
const resp = await authedFetch(t, cookie, `http://localhost/api${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
return { status: resp.status, body: (await resp.json()) as { deleted: number; mailboxes?: number; emails?: number; failed: { id: string; reason: string }[] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 往某个邮箱灌一封邮件(走真实两阶段 ingest),返回邮件 id
|
||||||
|
*/
|
||||||
|
async function ingestInto(t: Awaited<ReturnType<typeof makeTestEnv>>, recipient: string): Promise<string> {
|
||||||
|
const resp = await t.app.fetch(
|
||||||
|
await signedIngestRequest(t, new TextEncoder().encode(TEST_MAIL), { recipient }),
|
||||||
|
t.env,
|
||||||
|
);
|
||||||
|
if (resp.status !== 202) throw new Error(`ingest failed: ${resp.status}`);
|
||||||
|
return ((await resp.json()) as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("批量删除(POST /*/batch-delete)", () => {
|
||||||
|
test("批量删除邮件:连同 R2 原文/附件/delivery 一并清理;不存在的 id 计入 failed", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
const a = await ingestInto(t, "hello@example.test");
|
||||||
|
const b = await ingestInto(t, "hello@example.test");
|
||||||
|
|
||||||
|
const res = await batchDelete(t, cookie, "/emails/batch-delete", [a, b, "msg_not_exist"]);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.deleted).toBe(2);
|
||||||
|
expect(res.body.failed.length).toBe(1);
|
||||||
|
|
||||||
|
const left = await t.env.DB.prepare("SELECT id FROM emails").all<{ id: string }>();
|
||||||
|
expect(left.results.length).toBe(0);
|
||||||
|
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments").all<{ id: string }>();
|
||||||
|
expect(atts.results.length).toBe(0);
|
||||||
|
// R2 对象已删除
|
||||||
|
const obj = await t.env.R2.head("emails/2026/09/30/xx/msg_1/raw.eml").catch(() => null);
|
||||||
|
expect(obj).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("批量删除邮箱:级联删除其下邮件", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
const emailId = await ingestInto(t, "hello@example.test");
|
||||||
|
|
||||||
|
const res = await batchDelete(t, cookie, "/mailboxes/batch-delete", [t.mailboxId, "mbx_missing"]);
|
||||||
|
expect(res.body.deleted).toBe(1);
|
||||||
|
expect(res.body.emails).toBe(1);
|
||||||
|
expect(res.body.failed.length).toBe(1);
|
||||||
|
|
||||||
|
const mail = await t.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(t.mailboxId).first();
|
||||||
|
expect(mail).toBeNull();
|
||||||
|
const em = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(emailId).first();
|
||||||
|
expect(em).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("批量删除域名:级联删除 catch-all 邮箱与其邮件", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
const emailId = await ingestInto(t, "hello@example.test");
|
||||||
|
|
||||||
|
const res = await batchDelete(t, cookie, "/domains/batch-delete", [t.domainId, "dom_missing"]);
|
||||||
|
expect(res.body.deleted).toBe(1);
|
||||||
|
expect(res.body.mailboxes).toBe(1);
|
||||||
|
expect(res.body.emails).toBe(1);
|
||||||
|
expect(res.body.failed.length).toBe(1);
|
||||||
|
|
||||||
|
const dom = await t.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(t.domainId).first();
|
||||||
|
expect(dom).toBeNull();
|
||||||
|
const mb = await t.env.DB.prepare("SELECT id FROM mailboxes").all<{ id: string }>();
|
||||||
|
expect(mb.results.length).toBe(0);
|
||||||
|
const em = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(emailId).first();
|
||||||
|
expect(em).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("批量删除账号:软删 + 撤销 Worker 凭据;域名数据保留", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
|
||||||
|
const res = await batchDelete(t, cookie, "/accounts/batch-delete", [t.accountId, "acct_missing"]);
|
||||||
|
expect(res.body.deleted).toBe(1);
|
||||||
|
expect(res.body.failed.length).toBe(1);
|
||||||
|
|
||||||
|
const acct = await t.env.DB.prepare("SELECT status FROM cloudflare_accounts WHERE id = ?")
|
||||||
|
.bind(t.accountId)
|
||||||
|
.first<{ status: string }>();
|
||||||
|
expect(acct?.status).toBe("revoked");
|
||||||
|
const cred = await t.env.DB.prepare("SELECT status FROM worker_credentials WHERE cloudflare_account_id = ?")
|
||||||
|
.bind(t.accountId)
|
||||||
|
.first<{ status: string }>();
|
||||||
|
expect(cred?.status).toBe("revoked");
|
||||||
|
// 域名仍在
|
||||||
|
const dom = await t.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(t.domainId).first();
|
||||||
|
expect(dom).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("边界:空数组 / 非字符串 / 重复 id 均安全处理;需要登录", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
const empty = await batchDelete(t, cookie, "/emails/batch-delete", []);
|
||||||
|
expect(empty.status).toBe(200);
|
||||||
|
expect(empty.body.deleted).toBe(0);
|
||||||
|
|
||||||
|
// 非字符串与重复 id 被过滤/去重
|
||||||
|
const emailId = await ingestInto(t, "hello@example.test");
|
||||||
|
const mixed = await batchDelete(t, cookie, "/emails/batch-delete", [emailId, emailId, 123, null, ""]);
|
||||||
|
expect(mixed.body.deleted).toBe(1);
|
||||||
|
|
||||||
|
// 未登录 401
|
||||||
|
const anon = await t.app.fetch(
|
||||||
|
new Request("http://localhost/api/emails/batch-delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: ["x"] }),
|
||||||
|
}),
|
||||||
|
t.env,
|
||||||
|
);
|
||||||
|
expect(anon.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("单条删除语义不变:有邮箱的域名仍返回 409", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
const resp = await authedFetch(t, cookie, `http://localhost/api/domains/${t.domainId}`, { method: "DELETE" });
|
||||||
|
expect(resp.status).toBe(409);
|
||||||
|
const row = await t.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(t.domainId).first();
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
// 时间戳工具仍在使用(避免 noUnusedLocals 之外的隐式问题)
|
||||||
|
expect(typeof nowIso()).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
+96
-1
@@ -1,6 +1,7 @@
|
|||||||
/** 设置测试:全局 Webhook 读写与 key 语义、修改密码 */
|
/** 设置测试:全局 Webhook 读写与 key 语义、修改密码、Cloudflare OAuth 客户端配置 */
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
|
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
|
||||||
|
import { CF_OAUTH_SETTING_KEYS, getOAuthClient } from "../console/src/services/cfapi.ts";
|
||||||
|
|
||||||
describe("设置(/api/settings)", () => {
|
describe("设置(/api/settings)", () => {
|
||||||
test("全局 Webhook:保存 -> 读取(仅回 last4);key 缺省保留、空格清除", async () => {
|
test("全局 Webhook:保存 -> 读取(仅回 last4);key 缺省保留、空格清除", async () => {
|
||||||
@@ -52,6 +53,100 @@ describe("设置(/api/settings)", () => {
|
|||||||
expect(info.webhook.url).toBe("");
|
expect(info.webhook.url).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Cloudflare OAuth 客户端:设置页保存(secret 加密) -> 生效;env 兜底;清除", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
const info = async () =>
|
||||||
|
(await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
|
||||||
|
cf_oauth: { client_id: string; has_secret: boolean; secret_last4: string; source: string };
|
||||||
|
redirect_uri: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试环境默认带 env 凭据:mode 走 env 兜底
|
||||||
|
let cur = await info();
|
||||||
|
expect(cur.cf_oauth.source).toBe("env");
|
||||||
|
expect(cur.cf_oauth.client_id).toBe("test-client-id");
|
||||||
|
expect(cur.redirect_uri).toBe("http://localhost/api/oauth/cloudflare/callback");
|
||||||
|
|
||||||
|
// 在设置里保存:source 切到 settings,接口只回 last4
|
||||||
|
const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ cf_oauth: { client_id: "settings-client", secret: "settings-secret-9876" } }),
|
||||||
|
});
|
||||||
|
expect(save.status).toBe(200);
|
||||||
|
cur = await info();
|
||||||
|
expect(cur.cf_oauth.source).toBe("settings");
|
||||||
|
expect(cur.cf_oauth.client_id).toBe("settings-client");
|
||||||
|
expect(cur.cf_oauth.secret_last4).toBe("9876");
|
||||||
|
|
||||||
|
// secret 加密入库,接口与库中都不出现明文
|
||||||
|
const encRow = await t.env.DB.prepare("SELECT value FROM settings WHERE key = ?")
|
||||||
|
.bind(CF_OAUTH_SETTING_KEYS.secretEnc)
|
||||||
|
.first<{ value: string }>();
|
||||||
|
expect(encRow?.value).toBeTruthy();
|
||||||
|
expect(encRow!.value).not.toContain("settings-secret-9876");
|
||||||
|
|
||||||
|
// OAuth 授权 URL 用设置里的 client id
|
||||||
|
const start = (await (
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||||
|
).json()) as { url: string };
|
||||||
|
expect(start.url).toContain("client_id=settings-client");
|
||||||
|
|
||||||
|
// secret 缺省 = 保留;只改 client id 也生效
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ cf_oauth: { client_id: "settings-client-2" } }),
|
||||||
|
});
|
||||||
|
expect((await getOAuthClient(t.env))?.clientSecret).toBe("settings-secret-9876");
|
||||||
|
cur = await info();
|
||||||
|
expect(cur.cf_oauth.client_id).toBe("settings-client-2");
|
||||||
|
|
||||||
|
// DELETE 清除后台凭据:回落到 env 兜底
|
||||||
|
const del = await authedFetch(t, cookie, "http://localhost/api/settings/cf-oauth", { method: "DELETE" });
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
cur = await info();
|
||||||
|
expect(cur.cf_oauth.source).toBe("env");
|
||||||
|
expect(cur.cf_oauth.client_id).toBe("test-client-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Cloudflare OAuth 客户端:仅填 Client ID(无既有 secret)被拒;secret 传空格清除", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
// 清空 env 兜底,模拟"完全未配置"
|
||||||
|
delete t.env.CF_OAUTH_CLIENT_ID;
|
||||||
|
delete t.env.CF_OAUTH_CLIENT_SECRET;
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
|
||||||
|
const noSecret = await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ cf_oauth: { client_id: "only-id" } }),
|
||||||
|
});
|
||||||
|
expect(noSecret.status).toBe(400);
|
||||||
|
|
||||||
|
// 接入接口仍是 503(没有任何降级)
|
||||||
|
const start = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start");
|
||||||
|
expect(start.status).toBe(503);
|
||||||
|
|
||||||
|
// 先正常保存,再用空格清除
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ cf_oauth: { client_id: "cid", secret: "s1" } }),
|
||||||
|
});
|
||||||
|
let mode = (await (await authedFetch(t, cookie, "http://localhost/api/oauth/mode")).json()) as { mode: string };
|
||||||
|
expect(mode.mode).toBe("cloudflare");
|
||||||
|
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ cf_oauth: { client_id: "cid", secret: " " } }),
|
||||||
|
});
|
||||||
|
mode = (await (await authedFetch(t, cookie, "http://localhost/api/oauth/mode")).json()) as { mode: string };
|
||||||
|
expect(mode.mode).toBe("unconfigured");
|
||||||
|
});
|
||||||
|
|
||||||
test("修改密码:原密码错误被拒;成功后新密码可登录", async () => {
|
test("修改密码:原密码错误被拒;成功后新密码可登录", async () => {
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
const cookie = await loginCookie(t);
|
const cookie = await loginCookie(t);
|
||||||
|
|||||||
+38
-2
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* UI 冒烟测试(Playwright headless):
|
* UI 冒烟测试(Playwright headless):
|
||||||
* 登录 admin/admin → 概览/账号/域名/邮箱/邮件 各页渲染 → 设置模态窗口 → 生成 Key → 截图。
|
* 登录 admin/admin → 概览/账号/域名/邮箱/邮件 各页渲染 → 列表复选框批量选择 →
|
||||||
|
* 设置模态窗口(含 Cloudflare 接入 tab)→ 生成 Key → 截图。
|
||||||
* bun tests/ui-smoke.ts
|
* bun tests/ui-smoke.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -91,9 +92,25 @@ await page.locator(".ant-dropdown-trigger, .app-header .ant-dropdown-trigger").f
|
|||||||
await page.waitForSelector(".ant-dropdown:not(.ant-dropdown-hidden)", { timeout: 8000 });
|
await page.waitForSelector(".ant-dropdown:not(.ant-dropdown-hidden)", { timeout: 8000 });
|
||||||
await page.click(".ant-dropdown .ant-dropdown-menu-item:has-text('设置')");
|
await page.click(".ant-dropdown .ant-dropdown-menu-item:has-text('设置')");
|
||||||
await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 });
|
await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 });
|
||||||
check("设置模态窗口经头像下拉打开(仅 全局 Webhook / 账号密码 两 tab)", true);
|
const tabLabels = await page.locator(".ant-modal .ant-tabs-tab").allInnerTexts();
|
||||||
|
check(
|
||||||
|
"设置模态窗口经头像下拉打开(全局 Webhook / Cloudflare 接入 / 账号密码 三 tab)",
|
||||||
|
tabLabels.length === 3 && tabLabels.some((t) => t.includes("Cloudflare 接入")),
|
||||||
|
tabLabels.join(" | "),
|
||||||
|
);
|
||||||
await page.screenshot({ path: `${SHOT_DIR}/09-settings-webhook.png` });
|
await page.screenshot({ path: `${SHOT_DIR}/09-settings-webhook.png` });
|
||||||
|
|
||||||
|
// Cloudflare 接入 tab:回调地址可复制 + Client ID/Secret 表单
|
||||||
|
await page.click(".ant-modal .ant-tabs-tab:has-text('Cloudflare 接入')");
|
||||||
|
await page.waitForSelector(".ant-modal :text('Client ID')", { timeout: 8000 });
|
||||||
|
// 回调地址是 Typography.Paragraph(className=mono);同模态内还有 API Key 的 ****last4,按文本过滤
|
||||||
|
const redirectText =
|
||||||
|
(await page.locator(".ant-modal .mono").filter({ hasText: "/api/oauth/cloudflare/callback" }).first().textContent()) ?? "";
|
||||||
|
check("Cloudflare 接入 tab 显示授权回调地址", redirectText.includes("/api/oauth/cloudflare/callback"), redirectText.trim());
|
||||||
|
await page.screenshot({ path: `${SHOT_DIR}/09b-settings-cloudflare.png` });
|
||||||
|
await page.click(".ant-modal .ant-tabs-tab:has-text('全局 Webhook')");
|
||||||
|
await page.waitForSelector(".ant-modal :text('Webhook URL')", { timeout: 8000 });
|
||||||
|
|
||||||
// 单一 API/MCP Key:在 全局 Webhook tab 内生成
|
// 单一 API/MCP Key:在 全局 Webhook tab 内生成
|
||||||
const genBtn = page.getByRole("button", { name: /生成/ });
|
const genBtn = page.getByRole("button", { name: /生成/ });
|
||||||
await genBtn.waitFor({ state: "visible", timeout: 8000 });
|
await genBtn.waitFor({ state: "visible", timeout: 8000 });
|
||||||
@@ -107,6 +124,25 @@ await page.screenshot({ path: `${SHOT_DIR}/10-api-key-created.png` });
|
|||||||
const themeBtn = await page.locator("button[aria-label='切换主题']").count();
|
const themeBtn = await page.locator("button[aria-label='切换主题']").count();
|
||||||
check("主题切换按钮存在(头像左侧)", themeBtn === 1);
|
check("主题切换按钮存在(头像左侧)", themeBtn === 1);
|
||||||
|
|
||||||
|
// 列表复选框批量选择:四个列表页勾选表头全选后出现批量操作条
|
||||||
|
for (const path of ["/accounts", "/domains", "/mailboxes", "/inboxs"]) {
|
||||||
|
await page.goto(`${BASE}${path}`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForSelector(".ant-table-thead .ant-checkbox-input", { timeout: 8000 });
|
||||||
|
const boxes = page.locator(".ant-table-thead .ant-checkbox-input");
|
||||||
|
if ((await boxes.count()) === 0) {
|
||||||
|
check(`${path} 复选框批量选择`, false, "无表头复选框");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await boxes.first().click({ force: true });
|
||||||
|
const barVisible = await page
|
||||||
|
.waitForSelector("button:has-text('批量删除')", { timeout: 6000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
const selectedText = (await page.locator("text=/已选 \\d+ 项/").first().textContent().catch(() => "")) ?? "";
|
||||||
|
check(`${path} 复选框批量选择`, barVisible && /已选/.test(selectedText), selectedText.trim());
|
||||||
|
if (path === "/mailboxes") await page.screenshot({ path: `${SHOT_DIR}/11-batch-selected.png` });
|
||||||
|
}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
const failed = results.filter((r) => !r.ok);
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
|||||||
Reference in New Issue
Block a user