fix: validate mailbox credentials and isolate E2E APIs

This commit is contained in:
dreamhunter2333
2026-09-09 17:35:04 +08:00
parent f88852a353
commit b0a4a657aa
43 changed files with 564 additions and 158 deletions
+2 -1
View File
@@ -21,6 +21,7 @@
### Bug Fixes
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分站点认证错误以避免重复登录,并将 E2E 测试接口移出生产代码
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload``data-onerror` 属性
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
@@ -242,7 +243,7 @@
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点
### Improvements
+2 -1
View File
@@ -21,6 +21,7 @@
### Bug Fixes
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish site authentication errors to prevent login loops; move E2E test endpoints out of production code
- fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script
- fix: |Admin| Avoid briefly showing the Admin password dialog before access settings finish loading
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
@@ -242,7 +243,7 @@
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
- test: |Worker| Add `/admin/test/seed_mail` test endpoint
### Improvements
+2
View File
@@ -10,6 +10,8 @@ COPY worker/patches/ patches/
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
COPY worker/src/ src/
COPY e2e/fixtures/*.ts /app/e2e/fixtures/
RUN ln -s /app/worker/node_modules /app/e2e/node_modules
COPY worker/tsconfig.json ./
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
COPY ${WRANGLER_TOML} wrangler.toml
+2 -1
View File
@@ -51,7 +51,8 @@ Test results and HTML reports are exported via volumes:
## Configuration
The E2E worker uses `fixtures/wrangler.toml.e2e` with:
- `E2E_TEST_MODE = true` — enables test seed endpoint
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
- SMTP pointed at Mailpit container (`mailpit:1025`)
Test-only endpoints under `/__test/*` are registered in `e2e/fixtures/worker.ts` and are excluded from the production Worker.
+3
View File
@@ -0,0 +1,3 @@
export class EmailMessage {
constructor(public from: string, public to: string, public raw: string) {}
}
@@ -1,45 +1,41 @@
import { Context } from 'hono'
import { getBooleanValue } from '../utils'
// Direct DB insert — bypasses the email() handler.
const seedMail = async (c: Context<HonoCustomType>) => {
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
return c.text("Not available", 404);
}
const { address, source, raw, message_id, created_at, address_updated_at, address_created_at } = await c.req.json();
const seedMail = async (request: Request, env: Bindings) => {
const { address, source, raw, message_id, created_at, address_updated_at, address_created_at } = await request.json<{
address: string; source?: string; raw: string; message_id?: string;
created_at?: string; address_updated_at?: string; address_created_at?: string;
}>();
if (!address || !raw) {
return c.text("address and raw are required", 400);
return new Response("address and raw are required", { status: 400 });
}
if (raw.length > 1_000_000) {
return c.text("raw content too large", 400);
return new Response("raw content too large", { status: 400 });
}
if (message_id && message_id.length > 255) {
return c.text("message_id too long", 400);
return new Response("message_id too long", { status: 400 });
}
if (address_updated_at !== undefined) {
await c.env.DB.prepare(`UPDATE address SET updated_at = ? WHERE name = ?`)
await env.DB.prepare(`UPDATE address SET updated_at = ? WHERE name = ?`)
.bind(address_updated_at, address).run();
}
if (address_created_at !== undefined) {
await c.env.DB.prepare(`UPDATE address SET created_at = ? WHERE name = ?`)
await env.DB.prepare(`UPDATE address SET created_at = ? WHERE name = ?`)
.bind(address_created_at, address).run();
}
const msgId = message_id || `<e2e-${Date.now()}@test>`;
const { success } = await c.env.DB.prepare(
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (message_id, source, address, raw, created_at)`
+ ` VALUES (?, ?, ?, ?, COALESCE(?, datetime('now')))`
).bind(msgId, source || address, address, raw, created_at ?? null).run();
return c.json({ success });
return Response.json({ success });
};
// Exercises the real email() handler with a mock ForwardableEmailMessage.
const receiveMail = async (c: Context<HonoCustomType>) => {
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
return c.text("Not available", 404);
}
const { from, to, raw, ai_extract_result } = await c.req.json();
const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContext) => {
const { from, to, raw, ai_extract_result } = await request.json<{
from: string; to: string; raw: string; ai_extract_result?: unknown;
}>();
if (!from || !to || !raw) {
return c.text("from, to and raw are required", 400);
return new Response("from, to and raw are required", { status: 400 });
}
// Parse MIME headers (unfold continuation lines, extract key:value pairs)
@@ -61,24 +57,19 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
forward: async (recipient: string) => { state.forwardedTo.push(recipient); return { messageId: '' }; },
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
};
const { email: emailHandler } = await import('../email');
const { email: emailHandler } = await import('../../worker/src/email');
const aiExtractEnvOverrides: Partial<Bindings> = {
ENABLE_AI_EMAIL_EXTRACT: true,
AI: {
run: async () => ({ response: ai_extract_result })
} as unknown as Ai,
};
const env = ai_extract_result
? { ...c.env, ...aiExtractEnvOverrides }
: c.env;
const executionContext: ExecutionContext = {
waitUntil: () => {},
passThroughOnException: () => {},
props: {}
};
await emailHandler(mockMessage, env, executionContext);
const emailEnv = ai_extract_result
? { ...env, ...aiExtractEnvOverrides }
: env;
await emailHandler(mockMessage, emailEnv, ctx);
return c.json({
return Response.json({
success: !state.rejected,
replyCalled: state.replyCalled,
forwardedTo: state.forwardedTo,
+1 -1
View File
@@ -78,7 +78,7 @@ export async function seedTestMail(
`--${boundary}--`,
].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) {
+19
View File
@@ -0,0 +1,19 @@
import { Hono } from 'hono';
import worker from '../../worker/src/worker';
import { CONSTANTS } from '../../worker/src/constants';
import mailApi from './mail-api';
const testApi = new Hono<HonoCustomType>();
testApi.post('/seed_mail', c => mailApi.seedMail(c.req.raw, c.env));
testApi.post('/receive_mail', c => mailApi.receiveMail(c.req.raw, c.env, c.executionCtx as ExecutionContext));
testApi.get('/telegram_binding', async c => {
const address = c.req.query('address');
if (!address) return c.text('address is required', 400);
return c.json(await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`));
});
const app = new Hono<HonoCustomType>();
app.route('/__test', testApi);
app.all('*', c => worker.fetch(c.req.raw, c.env, c.executionCtx));
export default { ...worker, fetch: app.fetch };
+5 -2
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email"
main = "src/worker.ts"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -15,6 +15,7 @@ USER_ROLES = [
{ domains = [], role = "empty-role", prefix = "EMPTY" },
]
JWT_SECRET = "e2e-test-secret-key"
TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
@@ -29,7 +30,6 @@ DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_USER_ROLE = "admin"
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
CLEANUP_BATCH_SIZE = 10
SMTP_CONFIG = """
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
@@ -43,3 +43,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
binding = "DB"
database_name = "e2e-temp-email"
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+4 -2
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email_env_off"
main = "src/worker.ts"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -21,7 +21,6 @@ ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
"""
@@ -34,3 +33,6 @@ id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
binding = "DB"
database_name = "e2e-temp-email-env-off"
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+4 -2
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email_gzip"
main = "src/worker.ts"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -18,7 +18,6 @@ ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
ENABLE_MAIL_GZIP = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
@@ -32,3 +31,6 @@ id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
binding = "DB"
database_name = "e2e-temp-email-gzip"
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email"
main = "src/worker.ts"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -23,7 +23,6 @@ ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
"""
@@ -36,3 +35,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
binding = "DB"
database_name = "e2e-temp-email"
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+4 -2
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email_site_password"
main = "src/worker.ts"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -20,7 +20,6 @@ ENABLE_ADDRESS_PASSWORD = true
ADDRESS_CHECK_REGEX = "^(?!.*blocked).*$"
DISABLE_ADMIN_PASSWORD_CHECK = false
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
E2E_TEST_MODE = true
[[kv_namespaces]]
binding = "KV"
@@ -30,3 +29,6 @@ id = "e2e-test-kv-site-password-00000000-0000-0000-0000-000000000000"
binding = "DB"
database_name = "e2e-temp-email-site-password"
database_id = "e2e-test-db-site-password-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+11
View File
@@ -12,6 +12,7 @@
"devDependencies": {
"@playwright/test": "1.63.0",
"@types/ws": "^8.18.1",
"hono": "^4.13.7",
"ws": "^8.21.3"
}
},
@@ -86,6 +87,16 @@
"node": ">=18.0.0"
}
},
"node_modules/hono": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+1
View File
@@ -10,6 +10,7 @@
"devDependencies": {
"@playwright/test": "1.63.0",
"@types/ws": "^8.18.1",
"hono": "^4.13.7",
"ws": "^8.21.3"
},
"dependencies": {
+2 -2
View File
@@ -48,7 +48,7 @@ async function receiveGzipMail(
`--${boundary}--`,
].join('\r\n');
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
@@ -74,7 +74,7 @@ async function seedPlaintextMail(
opts.text || 'Hello plaintext from E2E',
].join('\r\n');
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/seed_mail`, {
data: { address, source: from, raw, message_id: messageId },
});
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
@@ -135,7 +135,7 @@ for (const { base, disabled } of [
});
}
async function seed(request: APIRequestContext, mailbox: Mailbox, updatedAt: string | null = OLD, createdAt?: string) {
await call(request, '/admin/test/seed_mail', {
await call(request, '/__test/seed_mail', {
method: 'POST', data: {
address: mailbox.address, raw: `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: activity\r\n\r\nBody`,
address_updated_at: updatedAt, address_created_at: createdAt, created_at: OLD,
@@ -170,7 +170,17 @@ for (const { base, disabled } of [
const inbox = await list(request, '/admin/mails', { address: mailbox.address });
const sent = await list(request, '/admin/sendbox', { address: mailbox.address });
const sender = await list(request, '/admin/address_sender', { address: mailbox.address });
const reply = await (await call(request, '/api/auto_reply', { headers: addressAuth(mailbox) })).json();
let replyMailbox = mailbox;
if (!address) {
await call(request, '/api/auto_reply', { headers: addressAuth(mailbox) }, 401);
// A new credential lets us check that no old auto-reply data survived cleanup.
const [name, domain] = mailbox.address.split('@');
replyMailbox = await (await call(request, '/admin/new_address', {
method: 'POST', data: { name, domain, enablePrefix: false },
})).json();
}
const reply = await (await call(request, '/api/auto_reply', { headers: addressAuth(replyMailbox) })).json();
if (!address) await call(request, `/admin/delete_address/${replyMailbox.address_id}`, { method: 'DELETE' });
const bound = await (await call(request, '/user_api/bind_address', { headers: userAuth(user) })).json();
return [Number(!!address), inbox.count, sent.count, sender.count, Number(!!reply.subject),
bound.results.filter((row: { name: string }) => row.name === mailbox.address).length];
@@ -267,7 +277,7 @@ for (const { base, disabled } of [
await bind(request, mailbox, user);
await seed(request, mailbox);
const raw = `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: incoming\r\n\r\nBody`;
const received = await call(request, '/admin/test/receive_mail', {
const received = await call(request, '/__test/receive_mail', {
method: 'POST', data: { from: 'sender@test.example.com', to: mailbox.address, raw },
});
expect((await received.json()).success).toBe(true);
@@ -359,7 +369,7 @@ for (const { base, disabled } of [
if (cleanType === 'mails_unknow') {
queryAddress = `unknown${Date.now()}@test.example.com`;
orphanAddresses.push(queryAddress);
await call(request, '/admin/test/seed_mail', {
await call(request, '/__test/seed_mail', {
method: 'POST', data: { address: queryAddress, raw: 'old unknown mail', created_at: OLD },
});
}
+124
View File
@@ -0,0 +1,124 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, seedTestMail } from '../../fixtures/test-helpers';
function signToken(payload: Record<string, unknown>) {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = createHmac('sha256', 'e2e-test-secret-key')
.update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${signature}`;
}
async function expectRejected(request: APIRequestContext, jwt: string) {
for (const [method, path] of [
['GET', '/api/settings'],
['GET', '/api/mails?limit=20&offset=0'],
['GET', '/api/mail/1'],
['GET', '/api/parsed_mails?limit=20&offset=0'],
['GET', '/api/parsed_mail/1'],
['GET', '/api/sendbox?limit=20&offset=0'],
['GET', '/api/auto_reply'],
['POST', '/api/webhook/settings'],
['POST', '/api/attachment/get_url'],
['POST', '/api/address_change_password'],
['POST', '/api/request_send_mail_access'],
['POST', '/api/send_mail'],
['PATCH', '/api/mails/1/read'],
['DELETE', '/api/mails/1'],
['DELETE', '/api/sendbox/1'],
['DELETE', '/api/clear_inbox'],
['DELETE', '/api/clear_sent_items'],
['DELETE', '/api/delete_address'],
]) {
const response = await request.fetch(`${WORKER_URL}${path}`, {
method,
headers: { Authorization: `Bearer ${jwt}` },
...(method === 'POST' || method === 'PATCH' ? { data: {} } : {}),
});
expect(response.status(), `${method} ${path}`).toBe(401);
}
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: { credential: jwt },
});
expect(login.status()).toBe(401);
const send = await request.post(`${WORKER_URL}/external/api/send_mail`, {
headers: { 'x-lang': 'en' },
data: { token: jwt },
});
expect(send.status()).toBe(400);
expect(await send.text()).toBe('Failed to send mail Invalid address credential');
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${jwt}`,
'x-user-token': signToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 60 }),
},
});
expect(bind.status()).toBe(401);
}
test('deleted credentials cannot access or delete a recreated mailbox', async ({ request }) => {
const name = `credential${Date.now()}`;
const create = async () => {
const response = await request.post(`${WORKER_URL}/api/new_address`, {
data: { name, domain: TEST_DOMAIN },
});
expect(response.ok()).toBe(true);
return await response.json();
};
const original = await create();
await deleteAddress(request, original.jwt);
await expectRejected(request, original.jwt);
const recreated = await create();
try {
expect(recreated.address).toBe(original.address);
expect(recreated.address_id).not.toBe(original.address_id);
await seedTestMail(request, recreated.address, { subject: 'New owner mail' });
await expectRejected(request, original.jwt);
const mails = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
headers: { Authorization: `Bearer ${recreated.jwt}` },
});
expect(mails.ok()).toBe(true);
expect((await mails.json()).count).toBe(1);
} finally {
await deleteAddress(request, recreated.jwt);
}
});
test('valid numeric/string IDs work; missing, invalid and mismatched IDs are rejected', async ({ request }) => {
const mailbox = await createTestAddress(request, 'credential-id');
try {
for (const address_id of [mailbox.address_id, String(mailbox.address_id)]) {
const token = signToken({ address: mailbox.address, address_id });
const settings = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(settings.ok()).toBe(true);
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: { credential: token },
});
expect(login.ok()).toBe(true);
}
for (const payload of [
{ address: mailbox.address },
...[0, -1, 1.5, true, null, '', '1e3', {}, Number.MAX_SAFE_INTEGER + 1]
.map(address_id => ({ address: mailbox.address, address_id })),
{ address: `other@${TEST_DOMAIN}`, address_id: mailbox.address_id },
{ address_id: mailbox.address_id },
]) {
const jwt = signToken(payload);
const response = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(response.status(), JSON.stringify(payload)).toBe(401);
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: { credential: jwt },
});
expect(login.status(), JSON.stringify(payload)).toBe(401);
}
} finally {
await deleteAddress(request, mailbox.jwt);
}
});
+1 -1
View File
@@ -175,7 +175,7 @@ async function seedTestMailWithReply(
text,
].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) {
+3 -3
View File
@@ -13,7 +13,7 @@ test.describe('Bounded cleanup', () => {
test('cleans at most one batch and continues on the next run', async ({ request }) => {
const address = `cleanup-batch-${Date.now()}@test.example.com`;
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
request.post(`${WORKER_URL}/admin/test/seed_mail`, {
request.post(`${WORKER_URL}/__test/seed_mail`, {
data: {
address,
raw: 'old cleanup mail',
@@ -40,7 +40,7 @@ test.describe('Bounded cleanup', () => {
const afterSecondCleanup = await listMails(request, address);
expect(afterSecondCleanup.count).toBe(0);
const recentMailResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
const recentMailResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, {
data: {
address,
raw: 'recent cleanup mail',
@@ -63,7 +63,7 @@ test.describe('Bounded cleanup', () => {
test('deletes one address batch and its related data', async ({ request }) => {
const oldAddress = await createTestAddress(request, 'cleanup-old');
const seedResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
const seedResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, {
data: {
address: oldAddress.address,
raw: 'address cleanup mail',
@@ -65,7 +65,7 @@ test.describe('Email forward domain normalization', () => {
`Forward domain normalization test`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
data: { from: 'sender@test.example.com', to, raw },
});
expect(res.ok()).toBe(true);
@@ -118,7 +118,7 @@ test.describe('Email forward domain normalization', () => {
`Forward domain boundary test`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
data: { from: 'sender@test.example.com', to, raw },
});
expect(res.ok()).toBe(true);
@@ -161,7 +161,7 @@ test.describe('Email forward domain normalization', () => {
`Forward catch-all domain test`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
data: { from: 'sender@test.example.com', to: address, raw },
});
expect(res.ok()).toBe(true);
+1
View File
@@ -19,6 +19,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
}
});
expect(res.status()).toBe(401);
expect(await res.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID', message: expect.any(String) });
});
});
+1 -1
View File
@@ -70,7 +70,7 @@ test.describe('Mail Deletion', () => {
`--${boundary}--`,
].join('\r\n');
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/__test/receive_mail`, {
data: { from, to: address, raw },
});
expect(seedRes.ok()).toBe(true);
+1 -1
View File
@@ -14,7 +14,7 @@ test.describe('Mail read status', () => {
test('keeps historical mail read and switches one new mail state', async ({ request }) => {
const mailbox = await createTestAddress(request, 'mail-read');
try {
const historical = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
const historical = await request.post(`${WORKER_URL}/__test/seed_mail`, {
data: {
address: mailbox.address,
source: 'sender@test.example.com',
+3
View File
@@ -83,6 +83,7 @@ test.describe('Redemption feature access boundaries', () => {
},
);
expect(blockedAdminResponse.status()).toBe(401);
expect(await blockedAdminResponse.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
const createResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
@@ -106,11 +107,13 @@ test.describe('Redemption feature access boundaries', () => {
{ data: { code } },
);
expect(missingPassword.status()).toBe(401);
expect(await missingPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
const wrongPassword = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
{ headers: { 'x-custom-auth': 'wrong' }, data: { code } },
);
expect(wrongPassword.status()).toBe(401);
expect(await wrongPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
}
const validPassword = await request.post(`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`, {
headers: SITE_HEADERS,
+29
View File
@@ -51,4 +51,33 @@ test.describe('Send Mail via SMTP', () => {
// Cleanup
await deleteAddress(request, jwt);
});
test('external sending stores mail fields without bearer credentials', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'external-sender');
const mail = {
from_name: 'External sender', to_name: 'Recipient', to_mail: 'recipient@test.example.com',
subject: `External ${Date.now()}`, content: 'External message', is_html: false,
};
try {
const listener = onMailpitMessage(message => message.Subject === mail.subject);
await listener.ready;
const response = await request.post(`${WORKER_URL}/external/api/send_mail`, {
data: { ...mail, token: jwt, extra_credential: 'must-not-be-stored' },
});
expect(response.ok(), await response.text()).toBe(true);
expect((await listener.message).From.Address).toBe(address);
const sendbox = await request.get(`${WORKER_URL}/api/sendbox?limit=20&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(sendbox.ok()).toBe(true);
const { results } = await sendbox.json();
expect(results).toHaveLength(1);
const stored = JSON.parse(results[0].raw);
expect(stored).toMatchObject(mail);
expect(stored).not.toHaveProperty('token');
expect(stored).not.toHaveProperty('extra_credential');
} finally {
await deleteAddress(request, jwt);
}
});
});
+1 -1
View File
@@ -18,7 +18,7 @@ test.describe('Telegram AI extraction rendering', () => {
'Telegram AI extraction realtime body',
].join('\r\n');
const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
const receiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, {
data: {
from: 'sender@test.example.com',
to: address,
+147
View File
@@ -0,0 +1,147 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
function initData(userId: number) {
const fields = {
auth_date: String(Math.floor(Date.now() / 1000)),
user: JSON.stringify({ id: userId }),
};
const key = createHmac('sha256', 'WebAppData').update('e2e-telegram-test-token').digest();
const hash = createHmac('sha256', key)
.update(Object.entries(fields).map(([name, value]) => `${name}=${value}`).join('\n'))
.digest('hex');
return new URLSearchParams({ ...fields, hash }).toString();
}
async function bind(request: APIRequestContext, userId: number, jwt: string) {
const response = await request.post(`${WORKER_URL}/telegram/bind_address`, {
data: { initData: initData(userId), jwt },
});
expect(response.ok(), await response.text()).toBe(true);
}
async function unbind(request: APIRequestContext, userId: number, address: string, status = 200) {
const response = await request.post(`${WORKER_URL}/telegram/unbind_address`, {
data: { initData: initData(userId), address },
});
expect(response.status(), await response.text()).toBe(status);
}
async function addressList(request: APIRequestContext, userId: number) {
const response = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
data: { initData: initData(userId) },
});
expect(response.ok(), await response.text()).toBe(true);
return response.json();
}
async function expectPushOwner(request: APIRequestContext, address: string, userId: number | null) {
const response = await request.get(`${WORKER_URL}/__test/telegram_binding`, {
params: { address },
});
expect(response.ok(), await response.text()).toBe(true);
expect(await response.json()).toBe(userId === null ? null : String(userId));
}
test('Telegram users can remove their own bindings after another user binds the mailbox', async ({ request }) => {
const mailbox = await createTestAddress(request, 'tg-owner');
const owner = Date.now();
const other = owner + 1;
try {
await bind(request, owner, mailbox.jwt);
await unbind(request, other, mailbox.address, 400);
await expectPushOwner(request, mailbox.address, owner);
await unbind(request, owner, mailbox.address);
await expectPushOwner(request, mailbox.address, null);
await bind(request, owner, mailbox.jwt);
await bind(request, other, mailbox.jwt);
await expectPushOwner(request, mailbox.address, other);
await unbind(request, owner, mailbox.address);
expect(await addressList(request, owner)).toEqual([]);
expect(await addressList(request, other)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
await expectPushOwner(request, mailbox.address, other);
await unbind(request, other, mailbox.address);
expect(await addressList(request, other)).toEqual([]);
await expectPushOwner(request, mailbox.address, null);
await bind(request, owner, mailbox.jwt);
await bind(request, other, mailbox.jwt);
await bind(request, owner, mailbox.jwt);
expect(await addressList(request, owner)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
await expectPushOwner(request, mailbox.address, owner);
await unbind(request, other, mailbox.address);
await expectPushOwner(request, mailbox.address, owner);
await unbind(request, owner, mailbox.address);
expect(await addressList(request, owner)).toEqual([]);
await expectPushOwner(request, mailbox.address, null);
} finally {
await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${mailbox.jwt}` },
});
}
});
test('stale Telegram credentials cannot unbind; internal mailbox cleanup still works', async ({ request }) => {
const original = await createTestAddress(request, 'tg-stale');
const owner = Date.now();
await bind(request, owner, original.jwt);
const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`);
expect(deletion.ok()).toBe(true);
const [name, domain] = original.address.split('@');
const creation = await request.post(`${WORKER_URL}/admin/new_address`, {
data: { name, domain, enablePrefix: false },
});
expect(creation.ok()).toBe(true);
const recreated = await creation.json();
try {
expect(recreated.address_id).not.toBe(original.address_id);
await unbind(request, owner, recreated.address, 400);
await expectPushOwner(request, recreated.address, owner);
const response = await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${recreated.jwt}` },
});
expect(response.ok(), await response.text()).toBe(true);
await expectPushOwner(request, recreated.address, null);
const listing = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
data: { initData: initData(owner) },
});
expect(listing.ok()).toBe(true);
expect(await listing.json()).toEqual([]);
} finally {
await request.delete(`${WORKER_URL}/admin/delete_address/${recreated.address_id}`);
}
});
test('Telegram unbind accepts a current credential after a stale credential for the same address', async ({ request }) => {
const original = await createTestAddress(request, 'tg-recreated');
const unrelated = await createTestAddress(request, 'tg-retained');
const owner = Date.now();
await bind(request, owner, original.jwt);
await bind(request, owner, unrelated.jwt);
const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`);
expect(deletion.ok()).toBe(true);
const [name, domain] = original.address.split('@');
const creation = await request.post(`${WORKER_URL}/admin/new_address`, {
data: { name, domain, enablePrefix: false },
});
expect(creation.ok()).toBe(true);
const recreated = await creation.json();
try {
expect(recreated.address_id).not.toBe(original.address_id);
await bind(request, owner, recreated.jwt);
await unbind(request, owner, recreated.address);
await expectPushOwner(request, recreated.address, null);
expect(await addressList(request, owner)).toEqual([{ address: unrelated.address, jwt: unrelated.jwt }]);
await expectPushOwner(request, unrelated.address, owner);
} finally {
for (const mailbox of [recreated, unrelated]) {
await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${mailbox.jwt}` },
});
}
}
});
+2 -2
View File
@@ -95,7 +95,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
`Webhook trigger test body`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
data: {
from,
to: address,
@@ -157,7 +157,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
`Should not trigger webhook`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
data: { from: 'sender@test.example.com', to: address, raw },
});
expect(res.ok()).toBe(true);
@@ -17,6 +17,36 @@ const openApiTestPage = async (page: Page) => {
const expiredTokenResponse = { status: 401, json: { code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', message: 'Access token expired' } };
for (const scenario of [
{ name: 'mailbox credential', path: '/api/settings', site: false, admin: false },
{ name: 'account credential', path: '/user_api/settings', site: false, admin: false },
{ name: 'site password', path: '/api/settings', site: true, admin: false },
{ name: 'site password on admin API', path: '/admin/db_version', site: true, admin: false },
{ name: 'admin password', path: '/admin/db_version', site: false, admin: true },
]) {
test(`Authentication dialog distinguishes ${scenario.name}`, async ({ page }) => {
await openApiTestPage(page);
await page.route(`**${scenario.path}`, route => route.fulfill(scenario.site
? { status: 401, json: { code: 'AUTH_SITE_PASSWORD_INVALID', message: 'Site password required' } }
: { status: 401, body: 'Invalid credential' }));
const result = await page.evaluate(async (path) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.openSettings.value.needAuth = true;
state.showAuth.value = false;
state.showAdminAuth.value = false;
try {
await api.fetch(path);
} catch {
return { site: state.showAuth.value, admin: state.showAdminAuth.value };
}
}, scenario.path);
expect(result).toEqual({ site: scenario.site, admin: scenario.admin });
});
}
for (const scenario of ['expired', 'expiring', 'valid', 'no account', 'login expired', 'wrong password', 'text zh', 'text en', 'retry expired', 'retry unauthorized', 'unmatched path', 'server error', 'json client error', 'json server error'] as const) {
test(`Access token response handling: ${scenario}`, async ({ page }) => {
await openApiTestPage(page);
+1
View File
@@ -1,4 +1,5 @@
export const ErrorCode = {
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
AUTH_SITE_PASSWORD_INVALID: 'AUTH_SITE_PASSWORD_INVALID',
AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
};
+4 -2
View File
@@ -8,6 +8,7 @@ import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
import { sanitizeHtml } from '../utils/sanitize-html'
import { APP_CONFIG } from '../config'
import { isUserAccessTokenError, createUserAccessTokenInterceptor } from './user-access-token-interceptor'
import { ErrorCode } from './error-codes'
const API_BASE = APP_CONFIG.API_BASE || "";
const {
@@ -63,10 +64,11 @@ const apiFetch = async (path, options = {}) => {
headers,
});
const response = await interceptResponse(path, initialResponse);
if (response.status === 401 && path.startsWith("/admin") && !isUserAccessTokenError(response)) {
const isSiteAuthError = response.status === 401 && response.data?.code === ErrorCode.AUTH_SITE_PASSWORD_INVALID;
if (response.status === 401 && path.startsWith("/admin") && !isUserAccessTokenError(response) && !isSiteAuthError) {
showAdminAuth.value = true;
}
if (response.status === 401 && openSettings.value.needAuth && !isUserAccessTokenError(response)) {
if (isSiteAuthError && openSettings.value.needAuth) {
showAuth.value = true;
}
if (response.status >= 300) {
+46
View File
@@ -0,0 +1,46 @@
import { Context, Next } from 'hono';
import { jwt } from 'hono/jwt';
import { Jwt } from 'hono/utils/jwt';
import i18n from './i18n';
export const validateAddressPayload = async (
c: Context<HonoCustomType>,
payload: Record<string, unknown>,
): Promise<JwtPayload | null> => {
const { address, address_id } = payload;
if (typeof address !== 'string' || !address) return null;
if (typeof address_id !== 'number'
&& (typeof address_id !== 'string' || !/^\d+$/.test(address_id))
) return null;
const addressId = Number(address_id);
if (!Number.isSafeInteger(addressId) || addressId <= 0) return null;
const exists = await c.env.DB.prepare(
`SELECT id FROM address WHERE id = ? AND name = ?`
).bind(addressId, address).first<number>('id');
return exists ? { address, address_id: addressId } : null;
};
export const verifyAddressToken = async (
c: Context<HonoCustomType>,
token: string,
): Promise<JwtPayload> => {
const payload = await Jwt.verify(token, c.env.JWT_SECRET, 'HS256');
const addressPayload = await validateAddressPayload(c, payload);
if (!addressPayload) {
throw new Error(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg);
}
return addressPayload;
};
export const addressJwtAuth = async (c: Context<HonoCustomType>, next: Next) => (
jwt({ secret: c.env.JWT_SECRET, alg: 'HS256' })(c, async () => {
const payload = await validateAddressPayload(c, c.get('jwtPayload'));
if (!payload) {
c.res = c.text(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg, 401);
return;
}
c.set('jwtPayload', payload);
await next();
})
);
-5
View File
@@ -17,7 +17,6 @@ import { sendMailbyAdmin, sendMailByBindingAdmin } from './send_mail'
import db_api from './db_api'
import ip_blacklist_settings from './ip_blacklist_settings'
import ai_extract_settings from './ai_extract_settings'
import e2e_test_api from './e2e_test_api'
import config_api from './config_api'
import redeem_code_api from '../redeem_api/admin_redeem_code_api'
@@ -118,7 +117,3 @@ api.post('/admin/ip_blacklist/settings', ip_blacklist_settings.saveIpBlacklistSe
// AI extract settings
api.get('/admin/ai_extract/settings', ai_extract_settings.getAiExtractSettings)
api.post('/admin/ai_extract/settings', ai_extract_settings.saveAiExtractSettings)
// E2E test endpoints
api.post('/admin/test/seed_mail', e2e_test_api.seedMail)
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail)
-4
View File
@@ -49,9 +49,6 @@ export const auto_reply = async (
contentType: 'text/plain',
data: results.message || "This is an auto-reply message, please reconact later."
});
if (getBooleanValue(env.E2E_TEST_MODE)) {
await message.reply(msg.asRaw());
} else {
const { EmailMessage } = await import('cloudflare:email');
const replyMessage = new EmailMessage(
toAddress,
@@ -61,7 +58,6 @@ export const auto_reply = async (
// @ts-ignore
await message.reply(replyMessage);
}
}
} catch (error) {
console.log("reply email error", error);
}
+1
View File
@@ -1,4 +1,5 @@
export enum ErrorCode {
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
AUTH_SITE_PASSWORD_INVALID = 'AUTH_SITE_PASSWORD_INVALID',
AUTH_USER_ACCESS_TOKEN_EXPIRED = 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
}
+1 -26
View File
@@ -62,32 +62,7 @@ const deleteMail = async (c: Context<HonoCustomType>) => {
};
const getSettings = async (c: Context<HonoCustomType>) => {
const { address, address_id } = c.get("jwtPayload")
const msgs = i18n.getMessagesbyContext(c);
if (address_id && address_id > 0) {
try {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ? `
).bind(address_id).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
try {
if (!address_id) {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ? `
).bind(address).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
const { address } = c.get("jwtPayload")
updateAddressUpdatedAt(c, address);
+5 -8
View File
@@ -1,5 +1,5 @@
import { Context, Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { verifyAddressToken } from '../address_auth';
import { createMimeMessage } from 'mimetext';
import { Resend } from 'resend';
import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer';
@@ -261,14 +261,11 @@ api.post('/api/send_mail', async (c) => {
})
api.post('/external/api/send_mail', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { token } = await c.req.json();
const body = await c.req.json();
try {
const { address } = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
if (!address) {
return c.text(msgs.AddressNotFoundMsg, 400)
}
const reqJson = await c.req.json();
const { address } = await verifyAddressToken(c, body?.token);
const { from_name, to_mail, to_name, subject, content, is_html } = body;
const reqJson = { from_name, to_mail, to_name, subject, content, is_html };
await sendMail(c, address as string, reqJson);
return c.json({ status: "ok" })
} catch (e) {
+4 -6
View File
@@ -1,8 +1,9 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { verifyAddressToken } from '../address_auth';
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils';
import i18n from '../i18n';
import { ErrorCode } from '../error_codes';
const api = new Hono<HonoCustomType>()
@@ -19,7 +20,7 @@ api.post('/open_api/site_login', async (c) => {
const passwords = getPasswords(c);
const hashedPasswords = await Promise.all(passwords.map(p => hashPassword(p)));
if (!hashedPasswords.length || !password || !hashedPasswords.includes(password)) {
return c.text(msgs.CustomAuthPasswordMsg, 401)
return c.json({ code: ErrorCode.AUTH_SITE_PASSWORD_INVALID, message: msgs.CustomAuthPasswordMsg }, 401)
}
return c.json({ success: true })
})
@@ -56,10 +57,7 @@ api.post('/open_api/credential_login', async (c) => {
return c.text(msgs.InvalidAddressCredentialMsg, 401)
}
try {
const payload = await Jwt.verify(credential, c.env.JWT_SECRET, "HS256");
if (!payload.address) {
return c.text(msgs.InvalidAddressCredentialMsg, 401)
}
await verifyAddressToken(c, credential);
} catch (error) {
return c.text(msgs.InvalidAddressCredentialMsg, 401)
}
+39 -26
View File
@@ -1,9 +1,11 @@
import { Context } from "hono";
import { Jwt } from "hono/utils/jwt";
import { validateAddressPayload, verifyAddressToken } from '../address_auth';
import { CONSTANTS } from "../constants";
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
import { LocaleMessages } from "../i18n/type";
import i18n from '../i18n';
export const tgUserNewAddress = async (
c: Context<HonoCustomType>, userId: string, address: string,
@@ -63,15 +65,7 @@ export const jwtListToAddressData = async (
const invalidJwtList = [] as string[];
for (const jwt of jwtList) {
try {
const { address, address_id } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(address_id).first("name");
if (!name) {
addressList.push(msgs.TgInvalidAddressMsg);
invalidJwtList.push(jwt);
continue;
}
const { address, address_id } = await verifyAddressToken(c, jwt);
addressList.push(address as string);
addressIdMap[address as string] = address_id as number;
} catch (e) {
@@ -87,13 +81,11 @@ export const bindTelegramAddress = async (
c: Context<HonoCustomType>, userId: string, jwt: string,
msgs: LocaleMessages
): Promise<string> => {
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
if (!address) {
throw Error(msgs.TgInvalidCredentialMsg);
}
const { address } = await verifyAddressToken(c, jwt);
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
if (address as string in addressIdMap) {
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString());
return address as string;
}
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
@@ -105,25 +97,45 @@ export const bindTelegramAddress = async (
return address as string;
}
const getTelegramBindings = async (c: Context<HonoCustomType>, userId: string) => {
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
return Promise.all(jwtList.map(async (jwt) => {
try {
return { jwt, payload: await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256") };
} catch (e) {
console.log(`解绑失败: ${(e as Error).message}`);
return { jwt, payload: null };
}
}));
}
const removeTelegramBinding = async (
c: Context<HonoCustomType>, userId: string, address: string,
bindings: Awaited<ReturnType<typeof getTelegramBindings>>
): Promise<boolean> => {
const newJwtList = bindings.filter(({ payload }) => payload?.address !== address).map(({ jwt }) => jwt);
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify(newJwtList));
const owner = await c.env.KV.get<string>(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
if (owner === userId) await c.env.KV.delete(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
return true;
}
export const unbindTelegramAddress = async (
c: Context<HonoCustomType>, userId: string, address: string
): Promise<boolean> => {
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const newJwtList = [];
for (const jwt of jwtList) {
const msgs = i18n.getMessagesbyContext(c);
const bindings = await getTelegramBindings(c, userId);
for (const { payload } of bindings) {
if (payload?.address !== address) continue;
try {
const { address: kvAddress } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
if (kvAddress == address) {
if (!await validateAddressPayload(c, payload)) continue;
} catch (e) {
console.log(`Failed to validate Telegram binding: ${(e as Error).message}`);
continue;
}
} catch (e) {
console.log(`解绑失败: ${(e as Error).message}`);
return await removeTelegramBinding(c, userId, address, bindings);
}
newJwtList.push(jwt);
}
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify(newJwtList));
await c.env.KV.delete(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
return true;
throw Error(msgs.TgAddressNotYoursMsg);
}
export const unbindTelegramByAddress = async (
@@ -132,7 +144,8 @@ export const unbindTelegramByAddress = async (
if (!c.env.KV) return true;
const userId = await c.env.KV.get<string>(`${CONSTANTS.TG_KV_PREFIX}:${address}`)
if (userId) {
return await unbindTelegramAddress(c, userId, address);
const bindings = await getTelegramBindings(c, userId);
return await removeTelegramBinding(c, userId, address, bindings);
}
return true;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { Context } from "hono";
import { Jwt } from 'hono/utils/jwt'
import { verifyAddressToken } from '../address_auth';
import { CONSTANTS } from "../constants";
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common";
import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils";
@@ -69,7 +69,7 @@ async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Respo
const res = [];
for (const jwt of jwtList) {
try {
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
const { address } = await verifyAddressToken(c, jwt);
res.push({ address, jwt });
} catch (e) {
console.error(`failed to verify jwt with error: ${e}`)
-3
View File
@@ -123,9 +123,6 @@ type Bindings = {
ENABLE_MAIL_GZIP: string | boolean | undefined
ENABLE_MAIL_READ_STATUS: string | boolean | undefined
CLEANUP_BATCH_SIZE: string | number | undefined
// E2E testing
E2E_TEST_MODE: string | boolean | undefined
}
type JwtPayload = {
+4 -4
View File
@@ -1,7 +1,7 @@
import { Context, Hono } from 'hono'
import { cors } from 'hono/cors';
import { jwt } from 'hono/jwt'
import { Jwt } from 'hono/utils/jwt'
import { addressJwtAuth } from './address_auth';
import { api as commonApi } from './commom_api';
import { api as openAuthApi } from './open_api/auth';
@@ -62,7 +62,7 @@ app.use('/*', async (c, next) => {
) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !passwords.includes(auth)) {
return c.text(msgs.CustomAuthPasswordMsg, 401)
return c.json({ code: ErrorCode.AUTH_SITE_PASSWORD_INVALID, message: msgs.CustomAuthPasswordMsg }, 401)
}
}
@@ -173,7 +173,7 @@ app.use('/api/*', async (c, next) => {
}
try {
return await jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
return await addressJwtAuth(c, next);
} catch (e) {
console.warn(e);
const lang = c.get("lang") || c.env.DEFAULT_LANG;
@@ -224,7 +224,7 @@ app.use('/user_api/*', async (c, next) => {
if (c.req.path.startsWith('/user_api/bind_address')
&& c.req.method === 'POST'
) {
return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
return addressJwtAuth(c, next);
}
await next();
});