mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-09 09:27:04 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcdf7d5729 |
@@ -21,7 +21,6 @@
|
|||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题
|
|
||||||
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload` 和 `data-onerror` 属性
|
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload` 和 `data-onerror` 属性
|
||||||
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
|
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
|
||||||
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail
|
|
||||||
- fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script
|
- 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| 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
|
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ USER_ROLES = [
|
|||||||
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
||||||
]
|
]
|
||||||
JWT_SECRET = "e2e-test-secret-key"
|
JWT_SECRET = "e2e-test-secret-key"
|
||||||
TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token"
|
|
||||||
BLACK_LIST = ""
|
BLACK_LIST = ""
|
||||||
ENABLE_USER_CREATE_EMAIL = true
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
ENABLE_USER_DELETE_EMAIL = true
|
ENABLE_USER_DELETE_EMAIL = true
|
||||||
|
|||||||
@@ -170,17 +170,7 @@ for (const { base, disabled } of [
|
|||||||
const inbox = await list(request, '/admin/mails', { address: mailbox.address });
|
const inbox = await list(request, '/admin/mails', { address: mailbox.address });
|
||||||
const sent = await list(request, '/admin/sendbox', { 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 sender = await list(request, '/admin/address_sender', { address: mailbox.address });
|
||||||
let replyMailbox = mailbox;
|
const reply = await (await call(request, '/api/auto_reply', { headers: addressAuth(mailbox) })).json();
|
||||||
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();
|
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),
|
return [Number(!!address), inbox.count, sent.count, sender.count, Number(!!reply.subject),
|
||||||
bound.results.filter((row: { name: string }) => row.name === mailbox.address).length];
|
bound.results.filter((row: { name: string }) => row.name === mailbox.address).length];
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
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`, {
|
|
||||||
data: { token: jwt },
|
|
||||||
});
|
|
||||||
expect(send.status()).toBe(401);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -51,33 +51,4 @@ test.describe('Send Mail via SMTP', () => {
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
await deleteAddress(request, jwt);
|
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,99 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 unbind(request, owner, mailbox.address);
|
|
||||||
|
|
||||||
await bind(request, owner, mailbox.jwt);
|
|
||||||
await bind(request, other, mailbox.jwt);
|
|
||||||
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 unbind(request, other, mailbox.address);
|
|
||||||
expect(await addressList(request, other)).toEqual([]);
|
|
||||||
|
|
||||||
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 unbind(request, other, mailbox.address);
|
|
||||||
await unbind(request, owner, mailbox.address);
|
|
||||||
expect(await addressList(request, owner)).toEqual([]);
|
|
||||||
} 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);
|
|
||||||
const response = await request.delete(`${WORKER_URL}/api/delete_address`, {
|
|
||||||
headers: { Authorization: `Bearer ${recreated.jwt}` },
|
|
||||||
});
|
|
||||||
expect(response.ok(), await response.text()).toBe(true);
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { WORKER_URL, WORKER_URL_SITE_PASSWORD } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
for (const scenario of ['expired role', 'valid role', 'invalid signature', 'missing expiry', 'admin password', 'wrong password'] as const) {
|
||||||
|
test(`Admin role token errors remain distinct from password errors: ${scenario}`, async ({ request }) => {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
user_id: 1, user_role: 'admin',
|
||||||
|
exp: scenario === 'missing expiry' ? undefined
|
||||||
|
: Math.floor(Date.now() / 1000) + (scenario === 'valid role' || scenario === 'invalid signature' ? 3600 : -60),
|
||||||
|
})).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', scenario === 'invalid signature' ? 'wrong-secret' : 'e2e-site-password-secret')
|
||||||
|
.update(`${header}.${payload}`).digest('base64url');
|
||||||
|
const headers: Record<string, string> = { 'x-custom-auth': 'e2e-site-pass', 'x-lang': 'en' };
|
||||||
|
if (scenario !== 'wrong password') headers['x-user-access-token'] = `${header}.${payload}.${signature}`;
|
||||||
|
if (scenario === 'admin password') headers['x-admin-auth'] = 'e2e-admin-pass';
|
||||||
|
if (scenario === 'wrong password') headers['x-admin-auth'] = 'wrong-password';
|
||||||
|
const response = await request.get(`${WORKER_URL_SITE_PASSWORD}/admin/db_version`, { headers });
|
||||||
|
if (scenario === 'expired role') {
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||||
|
message: 'Your access token has expired, please refresh the page',
|
||||||
|
});
|
||||||
|
} else if (scenario === 'missing expiry') {
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(await response.text()).toBe('Your access token has expired, please refresh the page');
|
||||||
|
} else if (scenario === 'valid role' || scenario === 'admin password') {
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(await response.text()).toBe('You need to provide the admin password to access this page');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generic authentication errors preserve their text messages and CORS headers', async ({ request }) => {
|
||||||
|
for (const { path, message } of [
|
||||||
|
{ path: '/api/settings', message: 'Invalid address credential' },
|
||||||
|
{ path: '/user_api/settings', message: 'Your token has expired, please login again' },
|
||||||
|
]) {
|
||||||
|
const response = await request.get(`${WORKER_URL}${path}`, { headers: { 'x-lang': 'en' } });
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(response.headers()['content-type']).toContain('text/plain');
|
||||||
|
expect(response.headers()['access-control-allow-origin']).toBe('*');
|
||||||
|
expect(await response.text()).toBe(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uncaught server errors return JSON with the original error detail', async ({ request }) => {
|
||||||
|
const response = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
data: Buffer.from('{invalid-json'),
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(500);
|
||||||
|
expect(response.headers()['content-type']).toContain('application/json');
|
||||||
|
expect(await response.json()).toEqual({ code: 'INTERNAL_SERVER_ERROR', message: expect.stringContaining('SyntaxError') });
|
||||||
|
});
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { FRONTEND_URL, WORKER_URL, createTestAddress, deleteAddress, hashPassword, getAddressSender, onMailpitMessage } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const accessToken = (expiresIn: number) => {
|
||||||
|
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expiresIn })).toString('base64url');
|
||||||
|
return `e30.${payload}.signature`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openApiTestPage = async (page: Page) => {
|
||||||
|
// Isolate API calls from the application's automatic settings requests.
|
||||||
|
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
|
||||||
|
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
|
||||||
|
}));
|
||||||
|
await page.goto(`${FRONTEND_URL}/api-test`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiredTokenResponse = { status: 401, json: { code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', message: 'Access token expired' } };
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const initialToken = accessToken(scenario === 'valid' ? 3600 : scenario === 'expiring' ? 20 : -60);
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
const path = scenario === 'unmatched path' ? '/open_api/settings' : '/admin/db_version';
|
||||||
|
let refreshCount = 0;
|
||||||
|
const attempts: string[] = [];
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
refreshCount++;
|
||||||
|
expect(route.request().headers()['x-user-token']).toBe('account-token');
|
||||||
|
await route.fulfill(scenario === 'login expired'
|
||||||
|
? { status: 401, contentType: 'text/plain', body: 'Please login again' }
|
||||||
|
: { json: { access_token: freshToken } });
|
||||||
|
});
|
||||||
|
await page.route(`**${path}`, async (route) => {
|
||||||
|
attempts.push(route.request().headers()['x-user-access-token']);
|
||||||
|
if (scenario.startsWith('json')) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: scenario === 'json client error' ? 403 : 503,
|
||||||
|
json: { code: 'OPERATION_FAILED', message: 'Operation failed' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scenario === 'server error') {
|
||||||
|
await route.fulfill({ status: 500, body: 'Server error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!['valid', 'expiring', 'wrong password'].includes(scenario)
|
||||||
|
&& (attempts.length === 1 || scenario === 'retry expired')) {
|
||||||
|
await route.fulfill(scenario.startsWith('text')
|
||||||
|
? { status: 401, body: scenario === 'text zh' ? '您的访问令牌已过期, 请刷新页面' : 'Your access token has expired, please refresh the page' }
|
||||||
|
: expiredTokenResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill(['wrong password', 'retry unauthorized'].includes(scenario)
|
||||||
|
? { status: 401, contentType: 'text/plain', body: 'Admin password required' }
|
||||||
|
: { json: { current_db_version: 'test-version' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await page.evaluate(async ({ initialToken, scenario, 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.userJwt.value = scenario === 'no account' ? '' : 'account-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
state.adminAuth.value = scenario === 'wrong password' ? 'wrong-password' : '';
|
||||||
|
state.showAdminAuth.value = false;
|
||||||
|
try {
|
||||||
|
return { data: await api.fetch(path), error: null, showAdminAuth: state.showAdminAuth.value };
|
||||||
|
} catch (error) {
|
||||||
|
return { data: null, error: String(error), showAdminAuth: state.showAdminAuth.value };
|
||||||
|
}
|
||||||
|
}, { initialToken, scenario, path });
|
||||||
|
|
||||||
|
const needsRefresh = !['valid', 'expiring', 'no account', 'wrong password', 'text zh', 'text en', 'unmatched path', 'server error', 'json client error', 'json server error'].includes(scenario);
|
||||||
|
expect(refreshCount).toBe(needsRefresh ? 1 : 0);
|
||||||
|
expect(attempts).toEqual(needsRefresh && scenario !== 'login expired' ? [initialToken, freshToken] : [initialToken]);
|
||||||
|
expect(result.showAdminAuth).toBe(['wrong password', 'retry unauthorized'].includes(scenario) || scenario.startsWith('text'));
|
||||||
|
if (scenario === 'login expired') expect(result.error).toContain('Please login again');
|
||||||
|
else if (['wrong password', 'retry unauthorized'].includes(scenario)) expect(result.error).toContain('Admin password required');
|
||||||
|
else if (['retry expired', 'no account', 'unmatched path'].includes(scenario)) expect(result.error).toContain('Access token expired');
|
||||||
|
else if (scenario === 'server error') expect(result.error).toContain('Server error');
|
||||||
|
else if (scenario.startsWith('text')) {
|
||||||
|
expect(result.error).toContain(scenario === 'text zh'
|
||||||
|
? '您的访问令牌已过期, 请刷新页面'
|
||||||
|
: 'Your access token has expired, please refresh the page');
|
||||||
|
}
|
||||||
|
else if (scenario.startsWith('json')) {
|
||||||
|
expect(result.error).toContain('Operation failed');
|
||||||
|
expect(result.error).not.toContain('[object Object]');
|
||||||
|
}
|
||||||
|
else expect(result.data).toEqual({ current_db_version: 'test-version' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('concurrent and late responses share one access token refresh', async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
const initialToken = accessToken(-60);
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
const paths = ['/admin/db_version', '/api/settings', '/api/send_mail',
|
||||||
|
'/user_api/bind_address', '/user_api/address/1/settings'];
|
||||||
|
let refreshCount = 0;
|
||||||
|
const initialPaths: string[] = [];
|
||||||
|
const attempts: string[] = [];
|
||||||
|
let release!: () => void;
|
||||||
|
let releaseLate!: () => void;
|
||||||
|
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
const lateResponse = new Promise<void>((resolve) => { releaseLate = resolve; });
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
refreshCount++;
|
||||||
|
await refreshPending;
|
||||||
|
await route.fulfill({ json: { access_token: freshToken } });
|
||||||
|
});
|
||||||
|
await page.route((url) => paths.includes(url.pathname), async (route) => {
|
||||||
|
if (route.request().headers()['x-user-access-token'] === initialToken) {
|
||||||
|
const requestPath = new URL(route.request().url()).pathname;
|
||||||
|
initialPaths.push(requestPath);
|
||||||
|
if (requestPath === paths.at(-1)) await lateResponse;
|
||||||
|
await route.fulfill(expiredTokenResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts.push(route.request().headers()['x-user-access-token']);
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
});
|
||||||
|
const pending = page.evaluate(async ({ initialToken, paths }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = 'account-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
return Promise.all(paths.map((path) => api.fetch(path, {
|
||||||
|
method: path === '/api/send_mail' ? 'POST' : 'GET',
|
||||||
|
})));
|
||||||
|
}, { initialToken, paths });
|
||||||
|
try {
|
||||||
|
await expect.poll(() => refreshCount).toBe(1);
|
||||||
|
await expect.poll(() => initialPaths.length).toBe(paths.length);
|
||||||
|
expect(attempts).toEqual([]);
|
||||||
|
release();
|
||||||
|
await expect.poll(() => attempts.length).toBe(paths.length - 1);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
releaseLate();
|
||||||
|
}
|
||||||
|
expect(await pending).toEqual(paths.map(() => ({ success: true })));
|
||||||
|
expect(refreshCount).toBe(1);
|
||||||
|
expect(attempts).toEqual(paths.map(() => freshToken));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of [400, 401, 500, 'network'] as const) {
|
||||||
|
test(`failed shared refresh preserves independent mailbox requests: ${failure}`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
const paths = ['/admin/db_version', '/user_api/bind_address', '/user_api/address/1/settings',
|
||||||
|
'/api/settings', '/api/send_mail'];
|
||||||
|
const attempts: string[] = [];
|
||||||
|
let initialResponses = 0;
|
||||||
|
page.on('response', async (response) => {
|
||||||
|
if (paths.includes(new URL(response.url()).pathname) && response.status() === 401) {
|
||||||
|
await response.finished();
|
||||||
|
initialResponses++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let refreshCount = 0;
|
||||||
|
let release!: () => void;
|
||||||
|
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
refreshCount++;
|
||||||
|
if (refreshCount > 1) {
|
||||||
|
await route.fulfill({ json: { access_token: freshToken } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refreshPending;
|
||||||
|
if (failure === 'network') await route.abort('failed');
|
||||||
|
else await route.fulfill({ status: failure, body: 'Refresh failed' });
|
||||||
|
});
|
||||||
|
await page.route((url) => paths.includes(url.pathname), async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname;
|
||||||
|
if (route.request().headers()['x-user-access-token'] && route.request().headers()['x-user-access-token'] !== freshToken) {
|
||||||
|
await route.fulfill(expiredTokenResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts.push(path);
|
||||||
|
await route.fulfill(path === '/api/send_mail'
|
||||||
|
? { status: 403, body: 'No send balance' }
|
||||||
|
: { json: { success: true } });
|
||||||
|
});
|
||||||
|
const pending = page.evaluate(async ({ paths, initialToken }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = 'account-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
state.adminAuth.value = '';
|
||||||
|
state.openSettings.value.needAuth = true;
|
||||||
|
state.showAuth.value = false;
|
||||||
|
state.showAdminAuth.value = false;
|
||||||
|
const results = await Promise.all(paths.map(async (path) => {
|
||||||
|
try {
|
||||||
|
return { data: await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' }) };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: String(error) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const flags = { showAuth: state.showAuth.value, showAdminAuth: state.showAdminAuth.value, loading: state.loading.value };
|
||||||
|
await api.fetch('/admin/db_version');
|
||||||
|
return { results, flags };
|
||||||
|
}, { paths, initialToken: accessToken(-60) });
|
||||||
|
try {
|
||||||
|
await expect.poll(() => initialResponses).toBe(paths.length);
|
||||||
|
await expect.poll(() => refreshCount).toBe(1);
|
||||||
|
expect(attempts).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
const { results, flags } = await pending;
|
||||||
|
for (const result of results.slice(0, 3)) {
|
||||||
|
expect(result.error).toContain(failure === 'network' ? 'Network Error' : 'Refresh failed');
|
||||||
|
}
|
||||||
|
expect(results[3].data).toEqual({ success: true });
|
||||||
|
expect(results[4].error).toContain('[403]: No send balance');
|
||||||
|
expect(flags).toEqual({ showAuth: false, showAdminAuth: false, loading: false });
|
||||||
|
expect(refreshCount).toBe(2);
|
||||||
|
expect(attempts.slice(0, 2).sort()).toEqual(['/api/send_mail', '/api/settings']);
|
||||||
|
expect(attempts.slice(2)).toEqual(['/admin/db_version']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const changedCredential of ['account', 'mailbox'] as const) {
|
||||||
|
test(`a changed ${changedCredential} stops the waiting request after refresh`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
const path = changedCredential === 'mailbox' ? '/api/send_mail' : '/admin/db_version';
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
let attempts = 0;
|
||||||
|
await page.route(`**${path}`, async (route) => {
|
||||||
|
attempts++;
|
||||||
|
await route.fulfill(attempts === 1 ? expiredTokenResponse : { json: { success: true } });
|
||||||
|
});
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
await page.evaluate(async (changedCredential) => {
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
if (changedCredential === 'account') {
|
||||||
|
state.userJwt.value = 'other-account';
|
||||||
|
state.userSettings.value = { access_token: 'other-access-token' };
|
||||||
|
} else {
|
||||||
|
state.jwt.value = 'other-mailbox';
|
||||||
|
}
|
||||||
|
}, changedCredential);
|
||||||
|
await route.fulfill({ json: { access_token: freshToken } });
|
||||||
|
});
|
||||||
|
const result = await page.evaluate(async ({ initialToken, 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.userJwt.value = 'account-token';
|
||||||
|
state.jwt.value = 'mailbox-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
state.adminAuth.value = '';
|
||||||
|
try {
|
||||||
|
await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return { error: String(error), token: state.userSettings.value.access_token, loading: state.loading.value };
|
||||||
|
}
|
||||||
|
}, { initialToken: accessToken(-60), path });
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: 'Error: User session changed, please retry',
|
||||||
|
token: changedCredential === 'account' ? 'other-access-token' : freshToken,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [scenario, query] of [
|
||||||
|
['expired account', ''],
|
||||||
|
['deleted account', ''],
|
||||||
|
['valid account', ''],
|
||||||
|
['missing account', ''],
|
||||||
|
['missing expiry', ''],
|
||||||
|
['expired account', '?refresh=1&source=e2e'],
|
||||||
|
['deleted account', '?refresh=1&source=e2e'],
|
||||||
|
['valid account', '?refresh=1&source=e2e'],
|
||||||
|
['missing account', '?refresh=1&source=e2e'],
|
||||||
|
['missing expiry', '?refresh=1&source=e2e'],
|
||||||
|
] as const) {
|
||||||
|
test(`mailbox operations use the real Worker when ${scenario}, query: ${query || 'none'}`, async ({ page, request }) => {
|
||||||
|
const address = await createTestAddress(request, 'refresh-mailbox-');
|
||||||
|
let userId: number | undefined;
|
||||||
|
try {
|
||||||
|
const email = `refresh-account-${Date.now()}@test.example.com`;
|
||||||
|
const created = await request.post(`${WORKER_URL}/admin/users`, {
|
||||||
|
data: { email, password: hashPassword('test-password-123') },
|
||||||
|
});
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const users = await request.get(`${WORKER_URL}/admin/users`, {
|
||||||
|
params: { limit: 10, offset: 0, query: email },
|
||||||
|
});
|
||||||
|
expect(users.ok()).toBe(true);
|
||||||
|
userId = (await users.json()).results.find((user: any) => user.user_email === email).id;
|
||||||
|
|
||||||
|
const sign = (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}`;
|
||||||
|
};
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const claims = { user_id: userId, user_email: email, iat: now - 3600 };
|
||||||
|
const userJwt = scenario === 'missing account' ? ''
|
||||||
|
: sign({ ...claims, exp: now + (scenario === 'expired account' ? -60 : 3600) });
|
||||||
|
const expiredAccessToken = sign({ ...claims, exp: scenario === 'missing expiry' ? undefined : now - 60, user_role: 'case-role' });
|
||||||
|
if (scenario === 'deleted account') {
|
||||||
|
expect((await request.delete(`${WORKER_URL}/admin/users/${userId}`)).ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise the browser API module without the UI's automatic settings requests.
|
||||||
|
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
|
||||||
|
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
|
||||||
|
}));
|
||||||
|
await page.goto(`${FRONTEND_URL}/api-test`);
|
||||||
|
const refreshStatuses: number[] = [];
|
||||||
|
let sendCount = 0;
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (new URL(response.url()).pathname === '/user_api/settings') refreshStatuses.push(response.status());
|
||||||
|
});
|
||||||
|
page.on('request', (req) => {
|
||||||
|
const url = new URL(req.url());
|
||||||
|
if (['/api/settings', '/api/send_mail'].includes(url.pathname)) expect(url.search).toBe(query);
|
||||||
|
if (url.pathname === '/api/send_mail') sendCount++;
|
||||||
|
});
|
||||||
|
const settings = await page.evaluate(async ({ userJwt, expiredAccessToken, mailboxJwt, query }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = userJwt;
|
||||||
|
state.userSettings.value.access_token = expiredAccessToken;
|
||||||
|
state.jwt.value = mailboxJwt;
|
||||||
|
state.adminAuth.value = '';
|
||||||
|
return api.fetch(`/api/settings${query}`);
|
||||||
|
}, { userJwt, expiredAccessToken, mailboxJwt: address.jwt, query });
|
||||||
|
expect(settings.address).toBe(address.address);
|
||||||
|
expect(settings.send_balance).toBe(10);
|
||||||
|
|
||||||
|
const subject = `Mailbox refresh regression ${scenario} ${Date.now()}`;
|
||||||
|
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||||
|
await listener.ready;
|
||||||
|
const [mail] = await Promise.all([
|
||||||
|
listener.message,
|
||||||
|
page.evaluate(async ({ subject, query }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
await api.fetch(`/api/send_mail${query}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { to_mail: 'recipient@test.example.com', subject, content: 'Independent mailbox credential', is_html: false },
|
||||||
|
});
|
||||||
|
}, { subject, query }),
|
||||||
|
]);
|
||||||
|
expect(mail.From.Address).toBe(address.address);
|
||||||
|
expect(sendCount).toBe(scenario === 'valid account' || scenario === 'missing expiry' ? 1 : 2);
|
||||||
|
expect((await getAddressSender(request, address.address)).balance).toBe(9);
|
||||||
|
const sent = await request.get(`${WORKER_URL}/admin/sendbox`, {
|
||||||
|
params: { address: address.address, limit: 10, offset: 0 },
|
||||||
|
});
|
||||||
|
expect(sent.ok()).toBe(true);
|
||||||
|
expect((await sent.json()).results).toHaveLength(1);
|
||||||
|
expect(refreshStatuses).toEqual(scenario === 'valid account' ? [200]
|
||||||
|
: scenario === 'missing account' || scenario === 'missing expiry' ? []
|
||||||
|
: scenario === 'expired account' ? [401, 401] : [400, 400]);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
if (userId !== undefined) await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const ErrorCode = {
|
||||||
|
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||||
|
AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { getFingerprint } from '../utils/fingerprint'
|
|||||||
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
|
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
|
||||||
import { sanitizeHtml } from '../utils/sanitize-html'
|
import { sanitizeHtml } from '../utils/sanitize-html'
|
||||||
import { APP_CONFIG } from '../config'
|
import { APP_CONFIG } from '../config'
|
||||||
|
import { isUserAccessTokenError, createUserAccessTokenInterceptor } from './user-access-token-interceptor'
|
||||||
|
|
||||||
const API_BASE = APP_CONFIG.API_BASE || "";
|
const API_BASE = APP_CONFIG.API_BASE || "";
|
||||||
const {
|
const {
|
||||||
@@ -21,6 +22,15 @@ const instance = axios.create({
|
|||||||
validateStatus: (status) => status >= 200 && status <= 500
|
validateStatus: (status) => status >= 200 && status <= 500
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const responseInterceptors = [createUserAccessTokenInterceptor(instance)];
|
||||||
|
|
||||||
|
const interceptResponse = async (path, response) => {
|
||||||
|
for (const { matches, handle } of responseInterceptors) {
|
||||||
|
if (matches(path, response)) return await handle(response);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
const apiFetch = async (path, options = {}) => {
|
const apiFetch = async (path, options = {}) => {
|
||||||
const showLoading = options.showLoading !== false;
|
const showLoading = options.showLoading !== false;
|
||||||
if (showLoading) loading.value = true;
|
if (showLoading) loading.value = true;
|
||||||
@@ -47,25 +57,26 @@ const apiFetch = async (path, options = {}) => {
|
|||||||
const authorizationHeader = safeBearerHeader(jwt.value);
|
const authorizationHeader = safeBearerHeader(jwt.value);
|
||||||
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
|
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
|
||||||
|
|
||||||
const response = await instance.request(path, {
|
const initialResponse = await instance.request(path, {
|
||||||
method: options.method || 'GET',
|
method: options.method || 'GET',
|
||||||
data: options.body || null,
|
data: options.body || null,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (response.status === 401 && path.startsWith("/admin")) {
|
const response = await interceptResponse(path, initialResponse);
|
||||||
|
if (response.status === 401 && path.startsWith("/admin") && !isUserAccessTokenError(response)) {
|
||||||
showAdminAuth.value = true;
|
showAdminAuth.value = true;
|
||||||
}
|
}
|
||||||
if (response.status === 401 && openSettings.value.needAuth) {
|
if (response.status === 401 && openSettings.value.needAuth && !isUserAccessTokenError(response)) {
|
||||||
showAuth.value = true;
|
showAuth.value = true;
|
||||||
}
|
}
|
||||||
if (response.status >= 300) {
|
if (response.status >= 300) {
|
||||||
throw new Error(`[${response.status}]: ${response.data}` || "error");
|
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
|
||||||
}
|
}
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
throw new Error(`Code ${error.response.status}: ${error.response.data}` || "error");
|
throw new Error(`Code ${error.response.status}: ${error.response.data?.message || error.response.data}`);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { AxiosHeaders } from 'axios';
|
||||||
|
import { useGlobalState } from '../store';
|
||||||
|
import { safeBearerHeader, safeHeaderValue } from '../utils/headers';
|
||||||
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
|
const { userJwt, userSettings, jwt } = useGlobalState();
|
||||||
|
const mailboxPaths = new Set(['/api/settings', '/api/send_mail']);
|
||||||
|
const paths = ['/admin/', ...mailboxPaths, '/user_api/bind_address', '/user_api/address/'];
|
||||||
|
const getPathname = (url) => new URL(url, window.location.origin).pathname;
|
||||||
|
|
||||||
|
export const isUserAccessTokenError = (response) => response.status === 401
|
||||||
|
&& response.data?.code === ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED;
|
||||||
|
|
||||||
|
const isCurrentSession = ({ headers }) =>
|
||||||
|
safeHeaderValue(headers.get('x-user-token')) === safeHeaderValue(userJwt.value)
|
||||||
|
&& safeHeaderValue(headers.get('Authorization')) === safeBearerHeader(jwt.value);
|
||||||
|
|
||||||
|
const matches = (path, response) => {
|
||||||
|
if (!isUserAccessTokenError(response)) return false;
|
||||||
|
const pathname = getPathname(path);
|
||||||
|
if (!paths.some(prefix => pathname.startsWith(prefix))) return false;
|
||||||
|
const { config } = response;
|
||||||
|
if (!isCurrentSession(config)) return false;
|
||||||
|
if (!safeHeaderValue(config.headers.get('x-user-access-token'))) return false;
|
||||||
|
|
||||||
|
const token = safeHeaderValue(config.headers.get('x-user-token'));
|
||||||
|
return Boolean(token) || mailboxPaths.has(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadUserSettings(token, client, headers) {
|
||||||
|
const response = await client.get('/user_api/settings', { headers });
|
||||||
|
if (response.status >= 300) {
|
||||||
|
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
|
||||||
|
}
|
||||||
|
if (safeHeaderValue(userJwt.value) === token) {
|
||||||
|
Object.assign(userSettings.value, response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUserAccessTokenInterceptor = (client) => {
|
||||||
|
const pendingRefreshes = new Map();
|
||||||
|
|
||||||
|
async function refreshUserSettings(token, headers) {
|
||||||
|
if (pendingRefreshes.has(token)) return await pendingRefreshes.get(token);
|
||||||
|
const request = loadUserSettings(token, client, headers);
|
||||||
|
pendingRefreshes.set(token, request);
|
||||||
|
try {
|
||||||
|
await request;
|
||||||
|
} finally {
|
||||||
|
pendingRefreshes.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAccessToken(config) {
|
||||||
|
const token = safeHeaderValue(config.headers.get('x-user-token'));
|
||||||
|
if (!token) return;
|
||||||
|
const currentToken = safeHeaderValue(userSettings.value.access_token);
|
||||||
|
if (currentToken !== safeHeaderValue(config.headers.get('x-user-access-token'))) return currentToken;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshUserSettings(token, config.headers);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mailboxPaths.has(getPathname(config.url))) throw error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return safeHeaderValue(userSettings.value.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matches,
|
||||||
|
handle: async ({ config }) => {
|
||||||
|
const accessToken = await resolveAccessToken(config);
|
||||||
|
if (!isCurrentSession(config)) throw new Error('User session changed, please retry');
|
||||||
|
const headers = new AxiosHeaders(config.headers);
|
||||||
|
headers.delete('x-user-access-token');
|
||||||
|
if (accessToken) headers.set('x-user-access-token', accessToken);
|
||||||
|
return await client.request({ ...config, headers });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Context, Next } from 'hono';
|
|
||||||
import { jwt } from 'hono/jwt';
|
|
||||||
import { Jwt } from 'hono/utils/jwt';
|
|
||||||
|
|
||||||
import i18n from './i18n';
|
|
||||||
|
|
||||||
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();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum ErrorCode {
|
||||||
|
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||||
|
AUTH_USER_ACCESS_TOKEN_EXPIRED = 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||||
|
}
|
||||||
@@ -62,7 +62,32 @@ const deleteMail = async (c: Context<HonoCustomType>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSettings = async (c: Context<HonoCustomType>) => {
|
const getSettings = async (c: Context<HonoCustomType>) => {
|
||||||
const { address } = c.get("jwtPayload")
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
updateAddressUpdatedAt(c, address);
|
updateAddressUpdatedAt(c, address);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Context, Hono } from 'hono'
|
import { Context, Hono } from 'hono'
|
||||||
import { verifyAddressToken } from '../address_auth';
|
import { Jwt } from 'hono/utils/jwt'
|
||||||
import { createMimeMessage } from 'mimetext';
|
import { createMimeMessage } from 'mimetext';
|
||||||
import { Resend } from 'resend';
|
import { Resend } from 'resend';
|
||||||
import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer';
|
import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer';
|
||||||
@@ -261,14 +261,15 @@ api.post('/api/send_mail', async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
api.post('/external/api/send_mail', async (c) => {
|
api.post('/external/api/send_mail', async (c) => {
|
||||||
const reqJson = await c.req.json();
|
const msgs = i18n.getMessagesbyContext(c);
|
||||||
const payload = await verifyAddressToken(c, reqJson?.token).catch(() => null);
|
const { token } = await c.req.json();
|
||||||
if (!payload) {
|
|
||||||
return c.text(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg, 401);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { from_name, to_mail, to_name, subject, content, is_html } = reqJson;
|
const { address } = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
|
||||||
await sendMail(c, payload.address, { from_name, to_mail, to_name, subject, content, is_html });
|
if (!address) {
|
||||||
|
return c.text(msgs.AddressNotFoundMsg, 400)
|
||||||
|
}
|
||||||
|
const reqJson = await c.req.json();
|
||||||
|
await sendMail(c, address as string, reqJson);
|
||||||
return c.json({ status: "ok" })
|
return c.json({ status: "ok" })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to send mail", e);
|
console.error("Failed to send mail", e);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { verifyAddressToken } from '../address_auth';
|
import { Jwt } from 'hono/utils/jwt'
|
||||||
|
|
||||||
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils';
|
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
@@ -56,7 +56,10 @@ api.post('/open_api/credential_login', async (c) => {
|
|||||||
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await verifyAddressToken(c, credential);
|
const payload = await Jwt.verify(credential, c.env.JWT_SECRET, "HS256");
|
||||||
|
if (!payload.address) {
|
||||||
|
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { Jwt } from "hono/utils/jwt";
|
import { Jwt } from "hono/utils/jwt";
|
||||||
import { verifyAddressToken } from '../address_auth';
|
|
||||||
import { CONSTANTS } from "../constants";
|
import { CONSTANTS } from "../constants";
|
||||||
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
|
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
|
||||||
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
|
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
|
||||||
import { LocaleMessages } from "../i18n/type";
|
import { LocaleMessages } from "../i18n/type";
|
||||||
import i18n from '../i18n';
|
|
||||||
|
|
||||||
export const tgUserNewAddress = async (
|
export const tgUserNewAddress = async (
|
||||||
c: Context<HonoCustomType>, userId: string, address: string,
|
c: Context<HonoCustomType>, userId: string, address: string,
|
||||||
@@ -65,9 +63,17 @@ export const jwtListToAddressData = async (
|
|||||||
const invalidJwtList = [] as string[];
|
const invalidJwtList = [] as string[];
|
||||||
for (const jwt of jwtList) {
|
for (const jwt of jwtList) {
|
||||||
try {
|
try {
|
||||||
const { address, address_id } = await verifyAddressToken(c, jwt);
|
const { address, address_id } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||||
addressList.push(address);
|
const name = await c.env.DB.prepare(
|
||||||
addressIdMap[address] = address_id;
|
`SELECT name FROM address WHERE id = ? `
|
||||||
|
).bind(address_id).first("name");
|
||||||
|
if (!name) {
|
||||||
|
addressList.push(msgs.TgInvalidAddressMsg);
|
||||||
|
invalidJwtList.push(jwt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addressList.push(address as string);
|
||||||
|
addressIdMap[address as string] = address_id as number;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addressList.push(msgs.TgInvalidCredentialMsg);
|
addressList.push(msgs.TgInvalidCredentialMsg);
|
||||||
invalidJwtList.push(jwt);
|
invalidJwtList.push(jwt);
|
||||||
@@ -81,11 +87,13 @@ export const bindTelegramAddress = async (
|
|||||||
c: Context<HonoCustomType>, userId: string, jwt: string,
|
c: Context<HonoCustomType>, userId: string, jwt: string,
|
||||||
msgs: LocaleMessages
|
msgs: LocaleMessages
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const { address } = await verifyAddressToken(c, jwt);
|
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||||
|
if (!address) {
|
||||||
|
throw Error(msgs.TgInvalidCredentialMsg);
|
||||||
|
}
|
||||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||||
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
|
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
|
||||||
if (address as string in addressIdMap) {
|
if (address as string in addressIdMap) {
|
||||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString());
|
|
||||||
return address as string;
|
return address as string;
|
||||||
}
|
}
|
||||||
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
||||||
@@ -97,9 +105,10 @@ export const bindTelegramAddress = async (
|
|||||||
return address as string;
|
return address as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeTelegramBinding = async (
|
export const unbindTelegramAddress = async (
|
||||||
c: Context<HonoCustomType>, userId: string, address: string, jwtList: string[]
|
c: Context<HonoCustomType>, userId: string, address: string
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
|
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||||
const newJwtList = [];
|
const newJwtList = [];
|
||||||
for (const jwt of jwtList) {
|
for (const jwt of jwtList) {
|
||||||
try {
|
try {
|
||||||
@@ -113,29 +122,17 @@ const removeTelegramBinding = async (
|
|||||||
newJwtList.push(jwt);
|
newJwtList.push(jwt);
|
||||||
}
|
}
|
||||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify(newJwtList));
|
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}`);
|
await c.env.KV.delete(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
|
||||||
if (owner === userId) await c.env.KV.delete(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unbindTelegramAddress = async (
|
|
||||||
c: Context<HonoCustomType>, userId: string, address: string
|
|
||||||
): Promise<boolean> => {
|
|
||||||
const msgs = i18n.getMessagesbyContext(c);
|
|
||||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
|
||||||
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
|
|
||||||
if (!Object.hasOwn(addressIdMap, address)) throw Error(msgs.TgAddressNotYoursMsg);
|
|
||||||
return await removeTelegramBinding(c, userId, address, jwtList);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const unbindTelegramByAddress = async (
|
export const unbindTelegramByAddress = async (
|
||||||
c: Context<HonoCustomType>, address: string
|
c: Context<HonoCustomType>, address: string
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (!c.env.KV) return true;
|
if (!c.env.KV) return true;
|
||||||
const userId = await c.env.KV.get<string>(`${CONSTANTS.TG_KV_PREFIX}:${address}`)
|
const userId = await c.env.KV.get<string>(`${CONSTANTS.TG_KV_PREFIX}:${address}`)
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
return await unbindTelegramAddress(c, userId, address);
|
||||||
return await removeTelegramBinding(c, userId, address, jwtList);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { verifyAddressToken } from '../address_auth';
|
import { Jwt } from 'hono/utils/jwt'
|
||||||
import { CONSTANTS } from "../constants";
|
import { CONSTANTS } from "../constants";
|
||||||
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common";
|
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common";
|
||||||
import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils";
|
import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils";
|
||||||
@@ -69,7 +69,7 @@ async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Respo
|
|||||||
const res = [];
|
const res = [];
|
||||||
for (const jwt of jwtList) {
|
for (const jwt of jwtList) {
|
||||||
try {
|
try {
|
||||||
const { address } = await verifyAddressToken(c, jwt);
|
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||||
res.push({ address, jwt });
|
res.push({ address, jwt });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`failed to verify jwt with error: ${e}`)
|
console.error(`failed to verify jwt with error: ${e}`)
|
||||||
|
|||||||
+15
-11
@@ -1,7 +1,7 @@
|
|||||||
import { Context, Hono } from 'hono'
|
import { Context, Hono } from 'hono'
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
|
import { jwt } from 'hono/jwt'
|
||||||
import { Jwt } from 'hono/utils/jwt'
|
import { Jwt } from 'hono/utils/jwt'
|
||||||
import { addressJwtAuth } from './address_auth';
|
|
||||||
|
|
||||||
import { api as commonApi } from './commom_api';
|
import { api as commonApi } from './commom_api';
|
||||||
import { api as openAuthApi } from './open_api/auth';
|
import { api as openAuthApi } from './open_api/auth';
|
||||||
@@ -13,6 +13,7 @@ import { api as telegramApi } from './telegram_api'
|
|||||||
import { api as redeemApi } from './redeem_api'
|
import { api as redeemApi } from './redeem_api'
|
||||||
|
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
import { ErrorCode } from './error_codes';
|
||||||
import { email } from './email';
|
import { email } from './email';
|
||||||
import { scheduled } from './scheduled';
|
import { scheduled } from './scheduled';
|
||||||
import { getPasswords, getBooleanValue, getDomains, checkIsAdmin, getEnvStringList } from './utils';
|
import { getPasswords, getBooleanValue, getDomains, checkIsAdmin, getEnvStringList } from './utils';
|
||||||
@@ -34,7 +35,7 @@ app.use('/*', cors());
|
|||||||
// error handler
|
// error handler
|
||||||
app.onError((err, c) => {
|
app.onError((err, c) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
return c.text(`${err.name} ${err.message}`, 500)
|
return c.json({ code: ErrorCode.INTERNAL_SERVER_ERROR, message: `${err.name} ${err.message}` }, 500)
|
||||||
})
|
})
|
||||||
// global middlewares
|
// global middlewares
|
||||||
app.use('/*', async (c, next) => {
|
app.use('/*', async (c, next) => {
|
||||||
@@ -134,16 +135,16 @@ const checkUserPayload = async (
|
|||||||
const checkoutUserRolePayload = async (
|
const checkoutUserRolePayload = async (
|
||||||
c: Context<HonoCustomType>,
|
c: Context<HonoCustomType>,
|
||||||
userId?: number
|
userId?: number
|
||||||
): Promise<void> => {
|
): Promise<Response | void> => {
|
||||||
try {
|
try {
|
||||||
const token = c.req.raw.headers.get("x-user-access-token");
|
const token = c.req.raw.headers.get("x-user-access-token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const payload = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
|
const payload = await Jwt.verify(token, c.env.JWT_SECRET, { alg: "HS256", exp: false });
|
||||||
// check expired
|
// check expired
|
||||||
if (!payload.exp) return;
|
if (!payload.exp) return;
|
||||||
// exp is in seconds
|
// exp is in seconds
|
||||||
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
||||||
return;
|
return c.json({ code: ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED, message: i18n.getMessagesbyContext(c).UserAcceesTokenExpiredMsg }, 401);
|
||||||
}
|
}
|
||||||
if (typeof payload?.user_role !== "string") return;
|
if (typeof payload?.user_role !== "string") return;
|
||||||
if (userId !== undefined && payload.user_id !== userId) return;
|
if (userId !== undefined && payload.user_id !== userId) return;
|
||||||
@@ -163,7 +164,8 @@ app.use('/api/*', async (c, next) => {
|
|||||||
if (c.req.path.startsWith("/api/settings")
|
if (c.req.path.startsWith("/api/settings")
|
||||||
|| c.req.path.startsWith("/api/send_mail")
|
|| c.req.path.startsWith("/api/send_mail")
|
||||||
) {
|
) {
|
||||||
await checkoutUserRolePayload(c);
|
const response = await checkoutUserRolePayload(c);
|
||||||
|
if (response) return response;
|
||||||
}
|
}
|
||||||
if (c.req.path.startsWith("/api/address_login")) {
|
if (c.req.path.startsWith("/api/address_login")) {
|
||||||
await next();
|
await next();
|
||||||
@@ -171,7 +173,7 @@ app.use('/api/*', async (c, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await addressJwtAuth(c, next);
|
return await jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
const lang = c.get("lang") || c.env.DEFAULT_LANG;
|
const lang = c.get("lang") || c.env.DEFAULT_LANG;
|
||||||
@@ -216,12 +218,13 @@ app.use('/user_api/*', async (c, next) => {
|
|||||||
|| c.req.path.startsWith("/user_api/address/")
|
|| c.req.path.startsWith("/user_api/address/")
|
||||||
) {
|
) {
|
||||||
const { user_id } = c.get("userPayload");
|
const { user_id } = c.get("userPayload");
|
||||||
await checkoutUserRolePayload(c, user_id);
|
const response = await checkoutUserRolePayload(c, user_id);
|
||||||
|
if (response) return response;
|
||||||
}
|
}
|
||||||
if (c.req.path.startsWith('/user_api/bind_address')
|
if (c.req.path.startsWith('/user_api/bind_address')
|
||||||
&& c.req.method === 'POST'
|
&& c.req.method === 'POST'
|
||||||
) {
|
) {
|
||||||
return addressJwtAuth(c, next);
|
return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
@@ -253,12 +256,13 @@ app.use('/admin/*', async (c, next) => {
|
|||||||
const access_token = c.req.raw.headers.get("x-user-access-token");
|
const access_token = c.req.raw.headers.get("x-user-access-token");
|
||||||
if (c.env.ADMIN_USER_ROLE && access_token) {
|
if (c.env.ADMIN_USER_ROLE && access_token) {
|
||||||
try {
|
try {
|
||||||
const payload = await Jwt.verify(access_token, c.env.JWT_SECRET, "HS256");
|
const payload = await Jwt.verify(access_token, c.env.JWT_SECRET, { alg: "HS256", exp: false });
|
||||||
// check expired
|
// check expired
|
||||||
if (!payload.exp) return c.text(msgs.UserAcceesTokenExpiredMsg, 401);
|
if (!payload.exp) return c.text(msgs.UserAcceesTokenExpiredMsg, 401);
|
||||||
// exp is in seconds
|
// exp is in seconds
|
||||||
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
||||||
return c.text(msgs.UserAcceesTokenExpiredMsg, 401)
|
if (getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)) return await next();
|
||||||
|
return c.json({ code: ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED, message: msgs.UserAcceesTokenExpiredMsg }, 401);
|
||||||
}
|
}
|
||||||
if (payload.user_role !== c.env.ADMIN_USER_ROLE) {
|
if (payload.user_role !== c.env.ADMIN_USER_ROLE) {
|
||||||
return c.text(msgs.UserRoleIsNotAdminMsg, 401)
|
return c.text(msgs.UserRoleIsNotAdminMsg, 401)
|
||||||
|
|||||||
Reference in New Issue
Block a user