From 66ab5a05df8b102af91c05f284cbfb2409570aac Mon Sep 17 00:00:00 2001 From: Dream Hunter Date: Sat, 5 Sep 2026 22:19:17 +0800 Subject: [PATCH] feat: add redemption code system (#1133) feat: add redemption code support --- CHANGELOG.md | 1 + CHANGELOG_EN.md | 1 + db/2026-09-01-redeem-codes.sql | 15 + db/schema.sql | 16 + e2e/Dockerfile.frontend | 1 + e2e/docker-compose.yml | 37 ++ e2e/fixtures/test-helpers.ts | 24 +- e2e/fixtures/wrangler.toml.e2e | 3 + e2e/fixtures/wrangler.toml.e2e.env-off | 1 + e2e/fixtures/wrangler.toml.e2e.site-password | 32 + e2e/package.json | 1 + e2e/scripts/docker-entrypoint.sh | 38 ++ e2e/tests/api/redeem-code-access.spec.ts | 305 +++++++++ e2e/tests/api/redeem-code-admin.spec.ts | 336 ++++++++++ e2e/tests/api/redeem-code.spec.ts | 628 ++++++++++++++++++ e2e/tests/browser/redeem-code.spec.ts | 387 +++++++++++ e2e/tests/unit/mailpit-events.test.mjs | 49 ++ frontend/src/api/index.js | 2 + .../components/AddressCredentialContent.vue | 311 +++++++++ .../src/components/AddressCredentialModal.vue | 292 +------- frontend/src/i18n/message-registry.ts | 352 +++++++++- frontend/src/router/index.js | 5 + frontend/src/store/index.js | 2 + frontend/src/utils/composables.js | 2 +- frontend/src/views/Admin.vue | 4 + frontend/src/views/Header.vue | 37 +- frontend/src/views/Redeem.vue | 627 +++++++++++++++++ frontend/src/views/admin/RedeemCodes.vue | 493 ++++++++++++++ frontend/src/views/common/Login.vue | 42 +- pages/functions/_middleware.js | 1 + vitepress-docs/docs/.vitepress/en.ts | 1 + vitepress-docs/docs/.vitepress/zh.ts | 1 + .../docs/en/guide/feature/redeem-code.md | 64 ++ vitepress-docs/docs/en/guide/worker-vars.md | 2 + .../docs/zh/guide/feature/redeem-code.md | 64 ++ vitepress-docs/docs/zh/guide/worker-vars.md | 2 + worker/src/admin_api/db_api.ts | 16 + worker/src/admin_api/index.ts | 10 + worker/src/admin_api/worker_config.ts | 2 + worker/src/commom_api.ts | 2 + worker/src/constants.ts | 2 +- worker/src/i18n/en.ts | 4 + worker/src/i18n/type.ts | 4 + worker/src/i18n/zh.ts | 4 + worker/src/mails_api/send_balance.ts | 2 +- .../src/redeem_api/admin_redeem_code_api.ts | 224 +++++++ worker/src/redeem_api/index.ts | 12 + worker/src/redeem_api/redeem_code.ts | 442 ++++++++++++ worker/src/redeem_api/redeem_code_query.ts | 38 ++ worker/src/redeem_api/redeem_result_query.ts | 71 ++ worker/src/types.d.ts | 2 + worker/src/worker.ts | 9 +- worker/wrangler.toml.template | 4 + 53 files changed, 4718 insertions(+), 309 deletions(-) create mode 100644 db/2026-09-01-redeem-codes.sql create mode 100644 e2e/fixtures/wrangler.toml.e2e.site-password create mode 100644 e2e/tests/api/redeem-code-access.spec.ts create mode 100644 e2e/tests/api/redeem-code-admin.spec.ts create mode 100644 e2e/tests/api/redeem-code.spec.ts create mode 100644 e2e/tests/browser/redeem-code.spec.ts create mode 100644 e2e/tests/unit/mailpit-events.test.mjs create mode 100644 frontend/src/components/AddressCredentialContent.vue create mode 100644 frontend/src/views/Redeem.vue create mode 100644 frontend/src/views/admin/RedeemCodes.vue create mode 100644 vitepress-docs/docs/en/guide/feature/redeem-code.md create mode 100644 vitepress-docs/docs/zh/guide/feature/redeem-code.md create mode 100644 worker/src/redeem_api/admin_redeem_code_api.ts create mode 100644 worker/src/redeem_api/index.ts create mode 100644 worker/src/redeem_api/redeem_code.ts create mode 100644 worker/src/redeem_api/redeem_code_query.ts create mode 100644 worker/src/redeem_api/redeem_result_query.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3134126..a8a5dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置 +- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示 - feat: |邮件| 新增可选的已读/未读状态,支持点击邮件自动已读和手动切换状态 - feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限 - feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126) diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index f761249..4a5007f 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -11,6 +11,7 @@ ### Features - feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html` +- feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation - feat: |Mail| Add optional read/unread status with click-to-read and manual status switching - feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit - feat: |Admin| Add one-click random email-name generation to the address creation page (issue #1126) diff --git a/db/2026-09-01-redeem-codes.sql b/db/2026-09-01-redeem-codes.sql new file mode 100644 index 0000000..4d55b8c --- /dev/null +++ b/db/2026-09-01-redeem-codes.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS redeem_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + redeem_type TEXT NOT NULL, + value TEXT NOT NULL, + result TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)), + expires_at DATETIME NOT NULL, + redeemed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type); diff --git a/db/schema.sql b/db/schema.sql index 3afc8e5..7913d71 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -120,3 +120,19 @@ CREATE TABLE IF NOT EXISTS user_passkeys ( CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id); + +CREATE TABLE IF NOT EXISTS redeem_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + redeem_type TEXT NOT NULL, + value TEXT NOT NULL, + result TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)), + expires_at DATETIME NOT NULL, + redeemed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type); diff --git a/e2e/Dockerfile.frontend b/e2e/Dockerfile.frontend index 5bbb34e..8d001bc 100644 --- a/e2e/Dockerfile.frontend +++ b/e2e/Dockerfile.frontend @@ -33,6 +33,7 @@ config.server = {\ "/admin": { target: workerTarget, changeOrigin: true },\ "/user_api": { target: workerTarget, changeOrigin: true },\ "/open_api": { target: workerTarget, changeOrigin: true },\ + "/redeem_api": { target: workerTarget, changeOrigin: true },\ "/external": { target: workerTarget, changeOrigin: true },\ "/health_check": { target: workerTarget, changeOrigin: true },\ },\ diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 678b124..b801eb1 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -90,6 +90,24 @@ services: start_period: 10s retries: 20 + worker-site-password: + build: + context: .. + dockerfile: e2e/Dockerfile.worker + args: + WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.site-password + ports: + - "8792:8792" + command: ["pnpm", "exec", "wrangler", "dev", "--port", "8792", "--ip", "0.0.0.0"] + depends_on: + - mailpit + healthcheck: + test: ["CMD", "curl", "-sf", "-H", "x-custom-auth: e2e-site-pass", "http://localhost:8792/health_check"] + interval: 3s + timeout: 5s + start_period: 10s + retries: 20 + frontend: build: context: .. @@ -100,6 +118,19 @@ services: worker: condition: service_healthy + frontend-env-off: + build: + context: .. + dockerfile: e2e/Dockerfile.frontend + ports: + - "5174:5174" + command: ["pnpm", "exec", "vite", "--port", "5174", "--host", "0.0.0.0"] + environment: + VITE_WORKER_URL: http://worker-env-off:8790 + depends_on: + worker-env-off: + condition: service_healthy + smtp-proxy: build: context: ../smtp_proxy_server @@ -147,7 +178,9 @@ services: WORKER_URL_ENV_OFF: http://worker-env-off:8790 WORKER_GZIP_URL: http://worker-gzip:8788 WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791 + WORKER_URL_SITE_PASSWORD: http://worker-site-password:8792 FRONTEND_URL: https://frontend:5173 + FRONTEND_URL_ENV_OFF: https://frontend-env-off:5174 MAILPIT_API: http://mailpit:8025/api SMTP_PROXY_HOST: smtp-proxy SMTP_PROXY_SMTP_PORT: "8025" @@ -167,8 +200,12 @@ services: condition: service_healthy worker-send-mail-domain: condition: service_healthy + worker-site-password: + condition: service_healthy frontend: condition: service_started + frontend-env-off: + condition: service_started smtp-proxy: condition: service_started smtp-proxy-tls: diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index 96723e2..5aabd39 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -7,7 +7,9 @@ export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || ''; export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || ''; export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || ''; export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || ''; +export const WORKER_URL_SITE_PASSWORD = process.env.WORKER_URL_SITE_PASSWORD || ''; export const FRONTEND_URL = process.env.FRONTEND_URL!; +export const FRONTEND_URL_ENV_OFF = process.env.FRONTEND_URL_ENV_OFF || ''; export const MAILPIT_API = process.env.MAILPIT_API!; export const TEST_DOMAIN = 'test.example.com'; @@ -165,14 +167,20 @@ export function onMailpitMessage( ws.on('open', () => readyResolve()); ws.on('message', (data: WebSocket.Data) => { - try { - const event = JSON.parse(data.toString()); - if (event.Type === 'new' && predicate(event.Data)) { - clearTimeout(timer); - ws.close(); - if (!settled) { settled = true; resolve(event.Data); } - } - } catch { /* ignore parse errors */ } + // Mailpit may batch multiple newline-separated events in one message. + for (const line of data.toString().split('\n')) { + if (settled) return; + try { + const event = JSON.parse(line); + if (event.Type === 'new' && predicate(event.Data)) { + clearTimeout(timer); + settled = true; + ws.close(); + resolve(event.Data); + return; + } + } catch { /* ignore malformed events without dropping the remaining ones */ } + } }); ws.on('close', () => { diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index 0059410..1d1c00a 100644 --- a/e2e/fixtures/wrangler.toml.e2e +++ b/e2e/fixtures/wrangler.toml.e2e @@ -19,11 +19,14 @@ BLACK_LIST = "" ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_DELETE_EMAIL = true ENABLE_MAIL_READ_STATUS = true +ENABLE_REDEEM_CODE = true +REDEEM_CODE_URL = "https://example.com/redeem-codes" ENABLE_AUTO_REPLY = true DEFAULT_SEND_BALANCE = 10 NO_LIMIT_SEND_ROLE = "case-role" ENABLE_ADDRESS_PASSWORD = true DISABLE_ADMIN_PASSWORD_CHECK = true +ADMIN_USER_ROLE = "admin" ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true E2E_TEST_MODE = true diff --git a/e2e/fixtures/wrangler.toml.e2e.env-off b/e2e/fixtures/wrangler.toml.e2e.env-off index e21f2e1..d84d396 100644 --- a/e2e/fixtures/wrangler.toml.e2e.env-off +++ b/e2e/fixtures/wrangler.toml.e2e.env-off @@ -13,6 +13,7 @@ JWT_SECRET = "e2e-test-secret-key-env-off" BLACK_LIST = "" ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_DELETE_EMAIL = false +ENABLE_REDEEM_CODE = false ENABLE_AUTO_REPLY = true DEFAULT_SEND_BALANCE = 10 ENABLE_ADDRESS_PASSWORD = true diff --git a/e2e/fixtures/wrangler.toml.e2e.site-password b/e2e/fixtures/wrangler.toml.e2e.site-password new file mode 100644 index 0000000..3d68e1e --- /dev/null +++ b/e2e/fixtures/wrangler.toml.e2e.site-password @@ -0,0 +1,32 @@ +name = "cloudflare_temp_email_site_password" +main = "src/worker.ts" +compatibility_date = "2025-04-01" +compatibility_flags = [ "nodejs_compat" ] +keep_vars = true + +[vars] +PREFIX = "tmp" +DEFAULT_DOMAINS = ["test.example.com"] +DOMAINS = ["test.example.com"] +USER_ROLES = [ + { domains = ["test.example.com"], role = "case-role", prefix = "" }, +] +ADMIN_USER_ROLE = "admin" +JWT_SECRET = "e2e-site-password-secret" +PASSWORDS = '["e2e-site-pass"]' +BLACK_LIST = "" +ENABLE_REDEEM_CODE = true +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" +id = "e2e-test-kv-site-password-00000000-0000-0000-0000-000000000000" + +[[d1_databases]] +binding = "DB" +database_name = "e2e-temp-email-site-password" +database_id = "e2e-test-db-site-password-00000000-0000-0000-0000-000000000000" diff --git a/e2e/package.json b/e2e/package.json index f643fec..7774152 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "test:unit": "node --experimental-strip-types --test tests/unit/*.test.mjs", "test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner", "test:down": "docker compose down -v" }, diff --git a/e2e/scripts/docker-entrypoint.sh b/e2e/scripts/docker-entrypoint.sh index a745319..5f39a52 100755 --- a/e2e/scripts/docker-entrypoint.sh +++ b/e2e/scripts/docker-entrypoint.sh @@ -59,6 +59,21 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then done fi +if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then + echo "==> Waiting for site-password worker at $WORKER_URL_SITE_PASSWORD ..." + for i in $(seq 1 60); do + if curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" "$WORKER_URL_SITE_PASSWORD/health_check" > /dev/null 2>&1; then + echo " Site-password worker ready after ${i}s" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: Site-password worker not ready after 60s" + exit 1 + fi + sleep 1 + done +fi + echo "==> Waiting for frontend at $FRONTEND_URL ..." for i in $(seq 1 60); do if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then @@ -72,6 +87,21 @@ for i in $(seq 1 60); do sleep 1 done +if [ -n "${FRONTEND_URL_ENV_OFF:-}" ]; then + echo "==> Waiting for env-off frontend at $FRONTEND_URL_ENV_OFF ..." + for i in $(seq 1 60); do + if curl --connect-timeout 5 --max-time 10 -skf "$FRONTEND_URL_ENV_OFF" > /dev/null 2>&1; then + echo " Env-off frontend ready after ${i}s" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: Env-off frontend not ready after 60s" + exit 1 + fi + sleep 1 + done +fi + echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..." for i in $(seq 1 30); do if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then @@ -110,5 +140,13 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then echo " Gzip worker database initialized" fi +if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then + echo "==> Initializing site-password worker database" + curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_initialize" > /dev/null + curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_migration" > /dev/null + echo " Site-password worker database initialized" +fi + echo "==> Running Playwright tests" +npm run test:unit exec npx playwright test "$@" diff --git a/e2e/tests/api/redeem-code-access.spec.ts b/e2e/tests/api/redeem-code-access.spec.ts new file mode 100644 index 0000000..d423fd5 --- /dev/null +++ b/e2e/tests/api/redeem-code-access.spec.ts @@ -0,0 +1,305 @@ +import { expect, test } from '@playwright/test'; +import { createHmac } from 'node:crypto'; +import { + WORKER_URL_ENV_OFF, + WORKER_URL_SITE_PASSWORD, +} from '../../fixtures/test-helpers'; + +const SITE_HEADERS = { 'x-custom-auth': 'e2e-site-pass' }; +const ADMIN_HEADERS = { ...SITE_HEADERS, 'x-admin-auth': 'e2e-admin-pass' }; +const futureExpiration = () => new Date(Date.now() + 3_600_000).toISOString(); + +const signTestToken = (payload: Record, secret = 'e2e-site-password-secret') => { + 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', secret).update(`${header}.${body}`).digest('base64url'); + return `${header}.${body}.${signature}`; +}; + +const tokenPayload = (role: string) => ({ + user_id: 1, + user_role: role, + exp: Math.floor(Date.now() / 1000) + 3600, +}); + +test.describe('Redemption feature access boundaries', () => { + test('the disabled switch hides every user and Admin endpoint', async ({ request }) => { + const settingsResponse = await request.get(`${WORKER_URL_ENV_OFF}/open_api/settings`); + expect(settingsResponse.ok()).toBe(true); + expect((await settingsResponse.json()).enableRedeemCode).toBe(false); + + const requests = [ + request.post(`${WORKER_URL_ENV_OFF}/redeem_api/query`, { data: { code: 'anything' } }), + request.post(`${WORKER_URL_ENV_OFF}/redeem_api/result`, { data: { code: 'anything' } }), + request.post(`${WORKER_URL_ENV_OFF}/redeem_api/redeem`, { + data: { code: 'anything', user_email: 'user@test.example.com' }, + }), + request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes?redeem_type=role`), + request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/export?redeem_type=role&limit=1`), + request.post(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/batch`, { + data: { + count: 1, + redeem_type: 'role', + value: 'case-role', + enabled: true, + expires_at: futureExpiration(), + }, + }), + request.put(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`, { + data: { + redeem_type: 'role', + value: 'case-role', + enabled: true, + expires_at: futureExpiration(), + }, + }), + request.delete(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`), + ]; + const responses = await Promise.all(requests); + for (const response of responses) { + expect(response.status()).toBe(404); + } + }); + + test('site password takes priority over otherwise public redemption APIs', async ({ request }) => { + const settingsResponse = await request.get(`${WORKER_URL_SITE_PASSWORD}/open_api/settings`); + expect(settingsResponse.ok()).toBe(true); + expect(await settingsResponse.json()).toMatchObject({ + needAuth: true, + enableRedeemCode: true, + }); + + const blockedAdminResponse = await request.post( + `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`, + { + headers: { 'x-admin-auth': 'e2e-admin-pass' }, + data: { + count: 1, + redeem_type: 'role', + value: 'case-role', + enabled: true, + expires_at: futureExpiration(), + }, + }, + ); + expect(blockedAdminResponse.status()).toBe(401); + + const createResponse = await request.post( + `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`, + { + headers: ADMIN_HEADERS, + data: { + count: 1, + redeem_type: 'role', + value: 'case-role', + enabled: true, + expires_at: futureExpiration(), + }, + }, + ); + expect(createResponse.ok()).toBe(true); + const code = (await createResponse.json()).codes[0] as string; + + for (const path of ['query', 'result', 'redeem']) { + const missingPassword = await request.post( + `${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`, + { data: { code } }, + ); + expect(missingPassword.status()).toBe(401); + const wrongPassword = await request.post( + `${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`, + { headers: { 'x-custom-auth': 'wrong' }, data: { code } }, + ); + expect(wrongPassword.status()).toBe(401); + } + const validPassword = await request.post(`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`, { + headers: SITE_HEADERS, + data: { code }, + }); + expect(validPassword.ok()).toBe(true); + expect(await validPassword.json()).toEqual({ + redeem_type: 'role', value: 'case-role', status: 'unused', + }); + + const listResponse = await request.get( + `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=role` + + `&limit=20&offset=0&query=${encodeURIComponent(code)}`, + { headers: ADMIN_HEADERS }, + ); + const row = (await listResponse.json()).results[0]; + const deleteResponse = await request.delete( + `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`, + { headers: ADMIN_HEADERS }, + ); + expect(deleteResponse.ok()).toBe(true); + }); + + test('special-address redemption preserves the configured address regex', async ({ request }) => { + const createResponse = await request.post( + `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`, + { + headers: ADMIN_HEADERS, + data: { + count: 1, + redeem_type: 'address_prefix_once', + value: '', + enabled: true, + expires_at: futureExpiration(), + }, + }, + ); + expect(createResponse.ok()).toBe(true); + const code = (await createResponse.json()).codes[0] as string; + + const redeemResponse = await request.post( + `${WORKER_URL_SITE_PASSWORD}/redeem_api/redeem`, + { + headers: SITE_HEADERS, + data: { code, name: 'blocked', domain: 'test.example.com' }, + }, + ); + expect(redeemResponse.status()).toBe(400); + + const queryResponse = await request.post( + `${WORKER_URL_SITE_PASSWORD}/redeem_api/query`, + { headers: SITE_HEADERS, data: { code } }, + ); + expect(queryResponse.ok()).toBe(true); + + const listResponse = await request.get( + `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=address_prefix_once` + + `&limit=20&offset=0&query=${encodeURIComponent(code)}`, + { headers: ADMIN_HEADERS }, + ); + const row = (await listResponse.json()).results[0]; + const deleteResponse = await request.delete( + `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`, + { headers: ADMIN_HEADERS }, + ); + expect(deleteResponse.ok()).toBe(true); + }); +}); + +test.describe('Redemption Admin authentication', () => { + const baseUrl = `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes`; + const listUrl = `${baseUrl}?redeem_type=address_prefix_once&limit=100&offset=0`; + const codeData = () => ({ + count: 1, + redeem_type: 'address_prefix_once', + value: 'auth', + enabled: true, + expires_at: futureExpiration(), + }); + const deniedCredentials: { name: string; headers: () => Record }[] = [ + { name: 'site password alone', headers: () => ({}) }, + { name: 'wrong Admin password', headers: () => ({ 'x-admin-auth': 'wrong' }) }, + { + name: 'mailbox JWT', + headers: () => ({ + Authorization: `Bearer ${signTestToken({ address: 'mail@test.example.com', address_id: 1 })}`, + }), + }, + { + name: 'user account JWT', + headers: () => ({ + 'x-user-token': signTestToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 3600 }), + }), + }, + { + name: 'non-Admin role token', + headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('case-role')) }), + }, + { + name: 'forged Admin role token', + headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('admin'), 'wrong-secret') }), + }, + { + name: 'expired Admin role token', + headers: () => ({ + 'x-user-access-token': signTestToken({ ...tokenPayload('admin'), exp: Math.floor(Date.now() / 1000) - 60 }), + }), + }, + { + name: 'Admin role token without expiration', + headers: () => ({ 'x-user-access-token': signTestToken({ user_id: 1, user_role: 'admin' }) }), + }, + { name: 'malformed role token', headers: () => ({ 'x-user-access-token': 'not-a-jwt' }) }, + ]; + + for (const credentials of deniedCredentials) { + test(`rejects ${credentials.name} on all five endpoints without changing data`, async ({ request }) => { + const original = codeData(); + const created = await request.post(`${baseUrl}/batch`, { headers: ADMIN_HEADERS, data: original }); + expect(created.ok()).toBe(true); + const code = (await created.json()).codes[0] as string; + const beforeResponse = await request.get(listUrl, { headers: ADMIN_HEADERS }); + expect(beforeResponse.ok()).toBe(true); + const before = await beforeResponse.json(); + const row = before.results.find((item: { code: string }) => item.code === code); + expect(row).toBeDefined(); + try { + const headers = { ...SITE_HEADERS, ...credentials.headers() }; + const responses = await Promise.all([ + request.get(listUrl, { headers }), + request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers }), + request.post(`${baseUrl}/batch`, { headers, data: original }), + request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'changed' } }), + request.delete(`${baseUrl}/${row.id}`, { headers }), + ]); + for (const response of responses) { + expect(response.status(), response.url()).toBe(401); + expect(await response.text()).not.toContain(code); + } + const after = await request.get(listUrl, { headers: ADMIN_HEADERS }); + expect(after.ok()).toBe(true); + expect(await after.json()).toEqual(before); + } finally { + const deleted = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS }); + expect(deleted.ok()).toBe(true); + } + }); + } + + for (const authType of ['password', 'role token'] as const) { + test(`accepts a valid Admin ${authType} for all five endpoints`, async ({ request }) => { + const headers: Record = authType === 'password' + ? ADMIN_HEADERS + : { ...SITE_HEADERS, 'x-user-access-token': signTestToken(tokenPayload('admin')) }; + const original = codeData(); + const created = await request.post(`${baseUrl}/batch`, { headers, data: original }); + expect(created.ok()).toBe(true); + const code = (await created.json()).codes[0] as string; + const listResponse = await request.get(listUrl, { headers }); + expect(listResponse.ok()).toBe(true); + const row = (await listResponse.json()).results.find((item: { code: string }) => item.code === code); + expect(row).toBeDefined(); + let deleted = false; + try { + const exported = await request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers }); + expect(exported.ok()).toBe(true); + expect(exported.headers()['content-type']).toContain('text/csv'); + expect(await exported.text()).toContain(code); + + const updated = await request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'updated' } }); + expect(updated.ok()).toBe(true); + const afterUpdate = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers }); + expect(afterUpdate.ok()).toBe(true); + expect((await afterUpdate.json()).results).toEqual([ + expect.objectContaining({ id: row.id, code, value: 'updated' }), + ]); + + const deletion = await request.delete(`${baseUrl}/${row.id}`, { headers }); + expect(deletion.ok()).toBe(true); + deleted = true; + const afterDelete = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers }); + expect(afterDelete.ok()).toBe(true); + expect(await afterDelete.json()).toEqual({ results: [], count: 0 }); + } finally { + if (!deleted) { + const cleanup = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS }); + expect(cleanup.ok()).toBe(true); + } + } + }); + } +}); diff --git a/e2e/tests/api/redeem-code-admin.spec.ts b/e2e/tests/api/redeem-code-admin.spec.ts new file mode 100644 index 0000000..c590a15 --- /dev/null +++ b/e2e/tests/api/redeem-code-admin.spec.ts @@ -0,0 +1,336 @@ +import { expect, test, type APIRequestContext } from '@playwright/test'; +import { deleteAddress, WORKER_URL } from '../../fixtures/test-helpers'; + +const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' }; +const MAX_BATCH_SIZE = 500; +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +type RedeemType = 'role' | 'send_balance' | 'address_prefix_once'; + +async function createCodes( + request: APIRequestContext, + { + count = 1, + type = 'role', + value = 'case-role', + enabled = true, + expiresAt = new Date(Date.now() + 3_600_000).toISOString(), + }: { + count?: unknown; + type?: string; + value?: unknown; + enabled?: unknown; + expiresAt?: unknown; + }, +) { + return await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, { + headers: ADMIN_HEADERS, + data: { + count, + redeem_type: type, + value, + enabled, + expires_at: expiresAt, + }, + }); +} + +async function listCodes( + request: APIRequestContext, + type: RedeemType, + query = '', +) { + const response = await request.get( + `${WORKER_URL}/admin/redeem_codes?redeem_type=${type}&limit=100&offset=0` + + (query ? `&query=${encodeURIComponent(query)}` : ''), + { headers: ADMIN_HEADERS }, + ); + expect(response.ok()).toBe(true); + return await response.json(); +} + +async function deleteCode(request: APIRequestContext, id: number) { + const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${id}`, { + headers: ADMIN_HEADERS, + }); + expect(response.ok()).toBe(true); +} + +test.describe('Redemption code Admin API', () => { + test('rejects malformed JSON when creating codes', async ({ request }) => { + const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, { + headers: { ...ADMIN_HEADERS, 'content-type': 'application/json' }, + data: '{', + }); + expect(response.status()).toBe(400); + }); + + test('reports the enabled switch and exposes the migrated table', async ({ request }) => { + const settingsResponse = await request.get(`${WORKER_URL}/open_api/settings`); + expect(settingsResponse.ok()).toBe(true); + expect((await settingsResponse.json()).enableRedeemCode).toBe(true); + + const listResponse = await request.get( + `${WORKER_URL}/admin/redeem_codes?redeem_type=role&limit=20&offset=0`, + { headers: ADMIN_HEADERS }, + ); + expect(listResponse.ok()).toBe(true); + await expect(listResponse.json()).resolves.toMatchObject({ + results: expect.any(Array), + count: expect.any(Number), + }); + }); + + test('batch generates UUIDs, filters by type, updates, and deletes', async ({ request }) => { + const createResponse = await createCodes(request, { count: 2 }); + expect(createResponse.ok()).toBe(true); + const created = await createResponse.json(); + expect(created).toMatchObject({ success: true, created: 2 }); + expect(created.codes).toHaveLength(2); + expect(new Set(created.codes).size).toBe(2); + expect(created.codes.every((code: string) => UUID_V4_PATTERN.test(code))).toBe(true); + const [firstCode, secondCode] = created.codes as string[]; + const queryPrefix = firstCode.slice(0, 8); + + const roleList = await listCodes(request, 'role', queryPrefix); + expect(roleList.count).toBe(1); + expect(roleList.results[0]).toMatchObject({ + code: firstCode, + redeem_type: 'role', + value: 'case-role', + enabled: 1, + redeemed: false, + result: null, + }); + const balanceList = await listCodes(request, 'send_balance', queryPrefix); + expect(balanceList.count).toBe(0); + + const firstId = roleList.results[0].id as number; + const expiresAt = new Date(Date.now() + 3_600_000).toISOString(); + const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstId}`, { + headers: ADMIN_HEADERS, + data: { + redeem_type: 'role', + value: 'empty-role', + enabled: false, + expires_at: expiresAt, + }, + }); + expect(updateResponse.ok()).toBe(true); + + const updatedList = await listCodes(request, 'role', firstCode); + expect(updatedList.count).toBe(1); + expect(updatedList.results[0]).toMatchObject({ + id: firstId, + code: firstCode, + value: 'empty-role', + enabled: 0, + }); + expect(updatedList.results[0].expires_at).toEqual(expect.any(String)); + + const secondList = await listCodes(request, 'role', secondCode); + await deleteCode(request, firstId); + await deleteCode(request, secondList.results[0].id); + expect((await listCodes(request, 'role', firstCode)).count).toBe(0); + }); + + test('normalizes typed values and accepts both empty and maximum usable prefixes', async ({ request }) => { + const emptyResponse = await createCodes(request, { + type: 'address_prefix_once', + value: ' ', + }); + expect(emptyResponse.ok()).toBe(true); + const emptyCode = (await emptyResponse.json()).codes[0] as string; + const maxResponse = await createCodes(request, { + type: 'address_prefix_once', + value: ` ${'A'.repeat(29)} `, + }); + expect(maxResponse.ok()).toBe(true); + const maxCode = (await maxResponse.json()).codes[0] as string; + const balanceResponse = await createCodes(request, { + type: 'send_balance', + value: ' 42 ', + }); + expect(balanceResponse.ok()).toBe(true); + const balanceCode = (await balanceResponse.json()).codes[0] as string; + + const emptyRow = (await listCodes(request, 'address_prefix_once', emptyCode)).results[0]; + const maxRow = (await listCodes(request, 'address_prefix_once', maxCode)).results[0]; + const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0]; + expect(emptyRow.value).toBe(''); + expect(maxRow.value).toBe('a'.repeat(29)); + expect(balanceRow.value).toBe('42'); + await deleteCode(request, emptyRow.id); + await deleteCode(request, maxRow.id); + await deleteCode(request, balanceRow.id); + }); + + test('rejects every invalid batch shape and typed business value', async ({ request }) => { + const invalidRequests = [ + { type: 'unknown', value: 'x' }, + { type: 'role', value: '' }, + { type: 'role', value: 'missing-role' }, + { type: 'role', value: 'admin' }, + { type: 'send_balance', value: '0' }, + { type: 'send_balance', value: '-1' }, + { type: 'send_balance', value: '1.5' }, + { type: 'send_balance', value: '1000000001' }, + { type: 'address_prefix_once', value: 'bad-' }, + { type: 'address_prefix_once', value: 'a'.repeat(30) }, + { count: 0 }, + { count: -1 }, + { count: 1.5 }, + { count: MAX_BATCH_SIZE + 1 }, + { count: '1' }, + { count: null }, + { enabled: 'yes' }, + { value: 1 }, + { expiresAt: null }, + { expiresAt: '' }, + { expiresAt: 'not-a-date' }, + { expiresAt: new Date(Date.now() - 60_000).toISOString() }, + ]; + + for (const invalidRequest of invalidRequests) { + const response = await createCodes(request, invalidRequest); + expect(response.status(), JSON.stringify(invalidRequest)).toBe(400); + } + }); + + test('rejects invalid updates and IDs', async ({ request }) => { + const expiresAt = new Date(Date.now() + 3_600_000).toISOString(); + const createResponse = await createCodes(request, { count: 2 }); + expect(createResponse.ok()).toBe(true); + const [firstCode, secondCode] = (await createResponse.json()).codes as string[]; + const firstRow = (await listCodes(request, 'role', firstCode)).results[0]; + const secondRow = (await listCodes(request, 'role', secondCode)).results[0]; + + const invalidId = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, { + headers: ADMIN_HEADERS, + data: { redeem_type: 'role', value: 'case-role', enabled: true, expires_at: expiresAt }, + }); + expect(invalidId.status()).toBe(400); + + const invalidRole = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, { + headers: ADMIN_HEADERS, + data: { redeem_type: 'role', value: 'admin', enabled: true, expires_at: expiresAt }, + }); + expect(invalidRole.status()).toBe(400); + + const wrongType = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, { + headers: ADMIN_HEADERS, + data: { + redeem_type: 'send_balance', + value: '1', + enabled: true, + expires_at: expiresAt, + }, + }); + expect(wrongType.status()).toBe(404); + + const invalidDelete = await request.delete(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, { + headers: ADMIN_HEADERS, + }); + expect(invalidDelete.status()).toBe(400); + + await deleteCode(request, firstRow.id); + await deleteCode(request, secondRow.id); + }); + + test('does not update a redeemed code', async ({ request }) => { + let code = ''; + let addressResult: { jwt?: string } | undefined; + try { + const createResponse = await createCodes(request, { + type: 'address_prefix_once', + value: 'locked', + }); + expect(createResponse.ok()).toBe(true); + code = (await createResponse.json()).codes[0] as string; + + const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, name: 'mail', domain: 'test.example.com' }, + }); + expect(redeemResponse.ok()).toBe(true); + addressResult = await redeemResponse.json(); + + const row = (await listCodes(request, 'address_prefix_once', code)).results[0]; + expect(row.redeemed).toBe(true); + expect(row.redeemed_at).toEqual(expect.any(String)); + const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${row.id}`, { + headers: ADMIN_HEADERS, + data: { + redeem_type: 'address_prefix_once', + value: 'changed', + enabled: false, + expires_at: new Date(Date.now() + 3_600_000).toISOString(), + }, + }); + expect(updateResponse.status()).toBe(409); + + const unchanged = (await listCodes(request, 'address_prefix_once', code)).results[0]; + expect(unchanged).toMatchObject({ + code, + value: 'locked', + enabled: 1, + redeemed: true, + }); + await deleteCode(request, row.id); + } finally { + if (addressResult?.jwt) await deleteAddress(request, addressResult.jwt); + const row = (await listCodes(request, 'address_prefix_once', code)).results[0]; + if (row) await deleteCode(request, row.id); + } + }); + + test('exports one selected type with its business columns and enforces the limit', async ({ request }) => { + const roleResponse = await createCodes(request, {}); + expect(roleResponse.ok()).toBe(true); + const roleCode = (await roleResponse.json()).codes[0] as string; + const balanceResponse = await createCodes(request, { + type: 'send_balance', + value: '25', + }); + expect(balanceResponse.ok()).toBe(true); + const balanceCode = (await balanceResponse.json()).codes[0] as string; + + const roleExport = await request.get( + `${WORKER_URL}/admin/redeem_codes/export?redeem_type=role&limit=100`, + { headers: ADMIN_HEADERS }, + ); + expect(roleExport.ok()).toBe(true); + expect(roleExport.headers()['content-type']).toContain('text/csv'); + expect(roleExport.headers()['content-disposition']).toContain('redeem-codes-role.csv'); + const roleCsv = await roleExport.text(); + expect(roleCsv).toContain('role,redeemed_user_id'); + expect(roleCsv).toContain(roleCode); + expect(roleCsv).not.toContain(balanceCode); + + const balanceExport = await request.get( + `${WORKER_URL}/admin/redeem_codes/export?redeem_type=send_balance&limit=100`, + { headers: ADMIN_HEADERS }, + ); + const balanceCsv = await balanceExport.text(); + expect(balanceCsv).toContain('amount,target_address'); + expect(balanceCsv).toContain(balanceCode); + expect(balanceCsv).not.toContain(roleCode); + + for (const query of [ + 'redeem_type=unknown&limit=1', + 'redeem_type=role&limit=0', + 'redeem_type=role&limit=10001', + 'redeem_type=role&limit=1x', + 'redeem_type=role', + ]) { + const response = await request.get(`${WORKER_URL}/admin/redeem_codes/export?${query}`, { + headers: ADMIN_HEADERS, + }); + expect(response.status(), query).toBe(400); + } + + const roleRow = (await listCodes(request, 'role', roleCode)).results[0]; + const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0]; + await deleteCode(request, roleRow.id); + await deleteCode(request, balanceRow.id); + }); +}); diff --git a/e2e/tests/api/redeem-code.spec.ts b/e2e/tests/api/redeem-code.spec.ts new file mode 100644 index 0000000..b42267a --- /dev/null +++ b/e2e/tests/api/redeem-code.spec.ts @@ -0,0 +1,628 @@ +import { expect, test, type APIRequestContext } from '@playwright/test'; +import { + WORKER_URL, + createTestAddress, + deleteAddress, + getAddressSender, + hashPassword, +} from '../../fixtures/test-helpers'; + +const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' }; + +type RedeemType = 'role' | 'send_balance' | 'address_prefix_once'; +type AddressRedeemResult = { + type: 'address_prefix_once'; + address: string; + address_id: number; + jwt: string; + password?: string; +}; + +const uniqueValue = (label: string) => ( + `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +); + +async function createCode( + request: APIRequestContext, + type: RedeemType, + value: string, + options: { enabled?: boolean; expiresAt?: string } = {}, +) { + const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, { + headers: ADMIN_HEADERS, + data: { + count: 1, + redeem_type: type, + value, + enabled: options.enabled ?? true, + expires_at: options.expiresAt ?? new Date(Date.now() + 3_600_000).toISOString(), + }, + }); + expect(response.ok()).toBe(true); + const body = await response.json(); + expect(body.codes).toHaveLength(1); + return body.codes[0] as string; +} + +async function listCode(request: APIRequestContext, type: RedeemType, code: string) { + const response = await request.get( + `${WORKER_URL}/admin/redeem_codes?redeem_type=${type}` + + `&limit=100&offset=0&query=${encodeURIComponent(code)}`, + { headers: ADMIN_HEADERS }, + ); + expect(response.ok()).toBe(true); + const body = await response.json(); + return body.results.find((row: { code: string }) => row.code === code); +} + +async function deleteCode(request: APIRequestContext, type: RedeemType, code: string) { + const row = await listCode(request, type, code); + if (!row) return; + const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, { + headers: ADMIN_HEADERS, + }); + expect(response.ok()).toBe(true); +} + +async function queryCode(request: APIRequestContext, code: unknown) { + return await request.post(`${WORKER_URL}/redeem_api/query`, { data: { code } }); +} + +async function queryResult(request: APIRequestContext, code: unknown) { + return await request.post(`${WORKER_URL}/redeem_api/result`, { data: { code } }); +} + +async function createUser(request: APIRequestContext) { + const email = `${uniqueValue('redeem-user')}@test.example.com`; + const createResponse = await request.post(`${WORKER_URL}/admin/users`, { + headers: ADMIN_HEADERS, + data: { email, password: hashPassword('redeem-user-password') }, + }); + expect(createResponse.ok()).toBe(true); + const listResponse = await request.get( + `${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`, + { headers: ADMIN_HEADERS }, + ); + const user = (await listResponse.json()).results.find( + (row: { user_email: string }) => row.user_email === email, + ); + expect(user).toBeTruthy(); + await setUserRole(request, user.id, null); + return { id: user.id as number, email }; +} + +async function setUserRole(request: APIRequestContext, userId: number, role: string | null) { + const response = await request.post(`${WORKER_URL}/admin/user_roles`, { + headers: ADMIN_HEADERS, + data: { user_id: userId, role_text: role }, + }); + expect(response.ok()).toBe(true); +} + +async function deleteUser(request: APIRequestContext, userId: number) { + await setUserRole(request, userId, null); + const response = await request.delete(`${WORKER_URL}/admin/users/${userId}`, { + headers: ADMIN_HEADERS, + }); + expect(response.ok()).toBe(true); +} + +test.describe('Redemption code query and guards', () => { + test('previews each supported type without consuming it', async ({ request }) => { + const roleCode = await createCode(request, 'role', 'case-role'); + const balanceCode = await createCode(request, 'send_balance', '12'); + const addressCode = await createCode(request, 'address_prefix_once', 'vip'); + try { + const roleResponse = await queryCode(request, roleCode); + expect(roleResponse.ok()).toBe(true); + expect(await roleResponse.json()).toEqual({ + redeem_type: 'role', value: 'case-role', status: 'unused', + }); + + const balanceResponse = await queryCode(request, balanceCode); + expect(balanceResponse.ok()).toBe(true); + expect(await balanceResponse.json()).toEqual({ + redeem_type: 'send_balance', value: '12', status: 'unused', + }); + + const addressResponse = await queryCode(request, addressCode); + expect(addressResponse.ok()).toBe(true); + expect(await addressResponse.json()).toEqual({ + redeem_type: 'address_prefix_once', + value: 'vip', + status: 'unused', + }); + + expect((await listCode(request, 'role', roleCode)).redeemed_at).toBeNull(); + expect((await listCode(request, 'send_balance', balanceCode)).redeemed_at).toBeNull(); + expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed_at).toBeNull(); + expect((await listCode(request, 'role', roleCode)).redeemed).toBe(false); + expect((await listCode(request, 'send_balance', balanceCode)).redeemed).toBe(false); + expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed).toBe(false); + expect((await queryResult(request, addressCode)).status()).toBe(400); + } finally { + await deleteCode(request, 'role', roleCode); + await deleteCode(request, 'send_balance', balanceCode); + await deleteCode(request, 'address_prefix_once', addressCode); + } + }); + + test('rejects missing, malformed, unknown, and disabled codes, but reports expired codes', async ({ request }) => { + const disabledCode = await createCode(request, 'role', 'case-role', { enabled: false }); + const expiredCode = await createCode(request, 'role', 'case-role', { + expiresAt: new Date(Date.now() + 5_000).toISOString(), + }); + try { + await new Promise((resolve) => setTimeout(resolve, 5_500)); + for (const code of [undefined, null, '', 'x'.repeat(257), uniqueValue('missing')]) { + expect((await queryCode(request, code)).status()).toBe(400); + } + expect((await queryCode(request, disabledCode)).status()).toBe(400); + const expiredResponse = await queryCode(request, expiredCode); + expect(expiredResponse.ok()).toBe(true); + expect(await expiredResponse.json()).toEqual({ + redeem_type: 'role', value: 'case-role', status: 'expired', + }); + } finally { + await deleteCode(request, 'role', disabledCode); + await deleteCode(request, 'role', expiredCode); + } + }); + + test('rejects malformed JSON request bodies', async ({ request }) => { + const options = { + headers: { 'content-type': 'application/json' }, + data: '{', + }; + for (const path of ['/query', '/result', '/redeem']) { + const response = await request.post(`${WORKER_URL}/redeem_api${path}`, options); + expect(response.status()).toBe(400); + } + }); + + test('uses the code type and does not consume when its required target is missing', async ({ request }) => { + const code = await createCode(request, 'send_balance', '9'); + try { + const mismatchResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, user_email: 'nobody@test.example.com' }, + }); + expect(mismatchResponse.status()).toBe(400); + expect((await queryCode(request, code)).ok()).toBe(true); + expect((await listCode(request, 'send_balance', code)).redeemed_at).toBeNull(); + } finally { + await deleteCode(request, 'send_balance', code); + } + }); +}); + +test.describe('Role redemption', () => { + test('concurrent different roles consume only the winning code', async ({ request }) => { + const user = await createUser(request); + const roles = ['case-role', 'empty-role']; + const codes = await Promise.all(roles.map((role) => createCode(request, 'role', role))); + try { + const responses = await Promise.all(codes.map((code) => ( + request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, user_email: user.email }, + }) + ))); + expect(responses.filter((response) => response.ok())).toHaveLength(1); + expect(responses.filter((response) => response.status() === 409)).toHaveLength(1); + const winner = responses.findIndex((response) => response.ok()); + const loser = 1 - winner; + const rows = await Promise.all(codes.map((code) => listCode(request, 'role', code))); + expect(rows[winner].redeemed).toBe(true); + expect(rows[loser]).toMatchObject({ redeemed: false, result: null, redeemed_at: null }); + const users = await request.get( + `${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`, + { headers: ADMIN_HEADERS }, + ); + expect((await users.json()).results[0].role_text).toBe(roles[winner]); + } finally { + for (const code of codes) await deleteCode(request, 'role', code); + await deleteUser(request, user.id); + } + }); + + test('applies a configured role by case-insensitive account email and consumes once', async ({ request }) => { + const user = await createUser(request); + const code = await createCode(request, 'role', 'case-role'); + try { + const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, user_email: user.email.toUpperCase() }, + }); + expect(response.ok()).toBe(true); + expect(await response.json()).toEqual({ + success: true, + type: 'role', + role: 'case-role', + user_email: user.email, + }); + + const userResponse = await request.get( + `${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`, + { headers: ADMIN_HEADERS }, + ); + expect((await userResponse.json()).results[0].role_text).toBe('case-role'); + + const row = await listCode(request, 'role', code); + expect(row.redeemed).toBe(true); + expect(row.redeemed_at).toEqual(expect.any(String)); + expect(JSON.parse(row.result)).toMatchObject({ + type: 'role', + user_id: user.id, + user_email: user.email, + role: 'case-role', + }); + const publicResult = await queryResult(request, code); + expect(publicResult.ok()).toBe(true); + expect(await publicResult.json()).toEqual({ + type: 'role', role: 'case-role', user_email: user.email, + }); + + const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, user_email: user.email }, + }); + expect(repeatedResponse.status()).toBe(400); + expect(await (await queryCode(request, code)).json()).toEqual({ + redeem_type: 'role', value: 'case-role', status: 'redeemed', + }); + } finally { + await deleteCode(request, 'role', code); + await deleteUser(request, user.id); + } + }); + + test('accepts the same existing role', async ({ request }) => { + const user = await createUser(request); + const code = await createCode(request, 'role', 'case-role'); + try { + await setUserRole(request, user.id, 'case-role'); + const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, user_email: user.email }, + }); + expect(response.ok()).toBe(true); + } finally { + await deleteCode(request, 'role', code); + await deleteUser(request, user.id); + } + }); + + test('keeps the code unused when the account is missing or has a conflicting role', async ({ request }) => { + const missingCode = await createCode(request, 'role', 'case-role'); + const conflictCode = await createCode(request, 'role', 'case-role'); + const user = await createUser(request); + try { + const missingResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code: missingCode, user_email: `${uniqueValue('missing')}@test.example.com` }, + }); + expect(missingResponse.status()).toBe(400); + expect((await queryCode(request, missingCode)).ok()).toBe(true); + + await setUserRole(request, user.id, 'empty-role'); + const conflictResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code: conflictCode, user_email: user.email }, + }); + expect(conflictResponse.status()).toBe(409); + expect((await queryCode(request, conflictCode)).ok()).toBe(true); + expect((await listCode(request, 'role', conflictCode)).result).toBeNull(); + } finally { + await deleteCode(request, 'role', missingCode); + await deleteCode(request, 'role', conflictCode); + await deleteUser(request, user.id); + } + }); +}); + +test.describe('Sending-credit redemption', () => { + test('preserves default credits before the mailbox is first opened', async ({ request }) => { + const address = await createTestAddress(request, 'rcfresh'); + const code = await createCode(request, 'send_balance', '7'); + try { + const before = await request.get( + `${WORKER_URL}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address.address)}`, + { headers: ADMIN_HEADERS }, + ); + expect((await before.json()).results).toHaveLength(0); + const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, address: address.address }, + }); + expect(response.ok()).toBe(true); + expect(await response.json()).toMatchObject({ amount: 7, balance: 17 }); + for (let index = 0; index < 2; index++) { + const settings = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${address.jwt}` }, + }); + expect(settings.ok()).toBe(true); + } + expect((await getAddressSender(request, address.address)).balance).toBe(17); + } finally { + await deleteCode(request, 'send_balance', code); + await deleteAddress(request, address.jwt); + } + }); + + test('coalesces NULL balance, preserves disabled state, records result, and consumes once', async ({ request }) => { + const address = await createTestAddress(request, 'rcnull'); + const code = await createCode(request, 'send_balance', '7'); + try { + const settingsResponse = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${address.jwt}` }, + }); + expect(settingsResponse.ok()).toBe(true); + const sender = await getAddressSender(request, address.address); + const nullResponse = await request.post(`${WORKER_URL}/admin/address_sender`, { + headers: ADMIN_HEADERS, + data: { + address: address.address, + address_id: sender.id, + balance: null, + enabled: false, + }, + }); + expect(nullResponse.ok()).toBe(true); + + const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, address: address.address.toUpperCase() }, + }); + expect(redeemResponse.ok()).toBe(true); + expect(await redeemResponse.json()).toEqual({ + success: true, + type: 'send_balance', + address: address.address, + amount: 7, + balance: 7, + }); + + const updatedSender = await getAddressSender(request, address.address); + expect(updatedSender.balance).toBe(7); + expect(updatedSender.enabled).toBe(0); + expect(JSON.parse((await listCode(request, 'send_balance', code)).result)).toMatchObject({ + type: 'send_balance', + address: address.address, + amount: 7, + }); + const publicResult = await queryResult(request, code); + expect(publicResult.ok()).toBe(true); + expect(await publicResult.json()).toEqual({ + type: 'send_balance', address: address.address, amount: 7, + }); + + const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, address: address.address }, + }); + expect(repeatedResponse.status()).toBe(400); + expect(await (await queryCode(request, code)).json()).toEqual({ + redeem_type: 'send_balance', value: '7', status: 'redeemed', + }); + } finally { + await deleteCode(request, 'send_balance', code); + await deleteAddress(request, address.jwt); + } + }); + + test('does not consume when the target address is missing', async ({ request }) => { + const code = await createCode(request, 'send_balance', '5'); + try { + const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, address: `${uniqueValue('missing')}@test.example.com` }, + }); + expect(response.status()).toBe(400); + expect((await queryCode(request, code)).ok()).toBe(true); + expect((await listCode(request, 'send_balance', code)).result).toBeNull(); + } finally { + await deleteCode(request, 'send_balance', code); + } + }); + + test('concurrent requests add the balance exactly once', async ({ request }) => { + const address = await createTestAddress(request, 'rcrace'); + const code = await createCode(request, 'send_balance', '5'); + try { + await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${address.jwt}` }, + }); + const before = await getAddressSender(request, address.address); + const responses = await Promise.all([ + request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, address: address.address }, + }), + request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, address: address.address }, + }), + ]); + expect(responses.filter((response) => response.ok())).toHaveLength(1); + expect(responses.filter((response) => [400, 409].includes(response.status()))).toHaveLength(1); + expect((await getAddressSender(request, address.address)).balance).toBe(before.balance + 5); + } finally { + await deleteCode(request, 'send_balance', code); + await deleteAddress(request, address.jwt); + } + }); +}); + +test.describe('Special-address redemption', () => { + test('uses the redemption prefix without the system prefix and repeats the complete original result', async ({ request }) => { + const code = await createCode(request, 'address_prefix_once', 'vip'); + const name = `a${Math.random().toString(36).slice(2, 8)}`; + let result: AddressRedeemResult | undefined; + try { + const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, name, domain: 'TEST.EXAMPLE.COM' }, + }); + expect(firstResponse.ok()).toBe(true); + result = await firstResponse.json(); + expect(result).toMatchObject({ + type: 'address_prefix_once', + address: `vip${name}@test.example.com`, + address_id: expect.any(Number), + jwt: expect.any(String), + password: expect.any(String), + }); + expect(result.address).not.toContain('tmpvip'); + + const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, name: 'ignored', domain: 'manual.example.com' }, + }); + expect(repeatedResponse.ok()).toBe(true); + expect(await repeatedResponse.json()).toEqual(result); + + const previewResponse = await queryCode(request, code); + expect(previewResponse.ok()).toBe(true); + const preview = await previewResponse.json(); + expect(preview).toEqual({ + redeem_type: 'address_prefix_once', value: 'vip', status: 'redeemed', + }); + const publicResult = await queryResult(request, code); + expect(publicResult.ok()).toBe(true); + expect(await publicResult.json()).toEqual({ + type: 'address_prefix_once', + address: result.address, + jwt: result.jwt, + password: result.password, + }); + const row = await listCode(request, 'address_prefix_once', code); + expect(JSON.parse(row.result)).toEqual(result); + } finally { + if (result?.jwt) await deleteAddress(request, result.jwt); + await deleteCode(request, 'address_prefix_once', code); + } + }); + + test('supports an empty prefix and the longest usable prefix', async ({ request }) => { + const emptyCode = await createCode(request, 'address_prefix_once', ''); + const maxCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29)); + const emptyName = `n${Math.random().toString(36).slice(2, 8)}`; + let emptyResult: AddressRedeemResult | undefined; + let maxResult: AddressRedeemResult | undefined; + try { + const emptyResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code: emptyCode, name: emptyName, domain: 'test.example.com' }, + }); + expect(emptyResponse.ok()).toBe(true); + emptyResult = await emptyResponse.json(); + expect(emptyResult.address).toBe(`${emptyName}@test.example.com`); + + const maxResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code: maxCode, name: 'z', domain: 'test.example.com' }, + }); + expect(maxResponse.ok()).toBe(true); + maxResult = await maxResponse.json(); + expect(maxResult.address.split('@')[0]).toBe(`${'a'.repeat(29)}z`); + } finally { + if (emptyResult?.jwt) await deleteAddress(request, emptyResult.jwt); + if (maxResult?.jwt) await deleteAddress(request, maxResult.jwt); + await deleteCode(request, 'address_prefix_once', emptyCode); + await deleteCode(request, 'address_prefix_once', maxCode); + } + }); + + test('keeps length and domain validation without consuming failed codes', async ({ request }) => { + const lengthCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29)); + const domainCode = await createCode(request, 'address_prefix_once', 'vip'); + try { + const lengthResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code: lengthCode, name: 'zz', domain: 'test.example.com' }, + }); + expect(lengthResponse.status()).toBe(400); + + const domainResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code: domainCode, name: 'valid', domain: 'invalid.example.com' }, + }); + expect(domainResponse.status()).toBe(400); + + expect((await queryCode(request, lengthCode)).ok()).toBe(true); + expect((await queryCode(request, domainCode)).ok()).toBe(true); + } finally { + await deleteCode(request, 'address_prefix_once', lengthCode); + await deleteCode(request, 'address_prefix_once', domainCode); + } + }); + + test('generates a valid random suffix when the user omits the name', async ({ request }) => { + const code = await createCode(request, 'address_prefix_once', 'rnd'); + let result: AddressRedeemResult | undefined; + try { + const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, domain: 'test.example.com' }, + }); + expect(response.ok()).toBe(true); + result = await response.json(); + const localPart = result.address.split('@')[0]; + expect(localPart.startsWith('rnd')).toBe(true); + expect(localPart.length).toBeLessThanOrEqual(30); + expect(localPart.length).toBeGreaterThan(3); + } finally { + if (result?.jwt) await deleteAddress(request, result.jwt); + await deleteCode(request, 'address_prefix_once', code); + } + }); + + test('does not return an address result after its original address is removed', async ({ request }) => { + const code = await createCode(request, 'address_prefix_once', 'removed'); + try { + const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, name: 'mail', domain: 'test.example.com' }, + }); + const first = await firstResponse.json(); + await deleteAddress(request, first.jwt); + + const repeatResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, name: 'ignored', domain: 'test.example.com' }, + }); + expect(repeatResponse.status()).toBe(400); + const resultResponse = await request.post(`${WORKER_URL}/redeem_api/result`, { + data: { code }, + }); + expect(resultResponse.status()).toBe(400); + } finally { + await deleteCode(request, 'address_prefix_once', code); + } + }); + + test('concurrent creations converge on one complete result', async ({ request }) => { + const code = await createCode(request, 'address_prefix_once', 'race'); + let result: AddressRedeemResult | undefined; + try { + const responses = await Promise.all([ + request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, name: `a${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' }, + }), + request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, name: `b${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' }, + }), + ]); + expect(responses.every((response) => response.ok())).toBe(true); + const results = await Promise.all(responses.map((response) => response.json())); + expect(results[1]).toEqual(results[0]); + result = results[0]; + expect(result.password).toEqual(expect.any(String)); + expect(JSON.parse((await listCode(request, 'address_prefix_once', code)).result)).toEqual(result); + } finally { + if (result?.jwt) await deleteAddress(request, result.jwt); + await deleteCode(request, 'address_prefix_once', code); + } + }); + + test('expiry stops retrieval of an existing result', async ({ request }) => { + const expiredCode = await createCode(request, 'address_prefix_once', 'exp', { + expiresAt: new Date(Date.now() + 5_000).toISOString(), + }); + let expiredResult: AddressRedeemResult | undefined; + try { + expiredResult = await (await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code: expiredCode, name: 'mail', domain: 'test.example.com' }, + })).json(); + await new Promise((resolve) => setTimeout(resolve, 5_500)); + + expect((await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code: expiredCode }, + })).status()).toBe(400); + expect(await (await queryCode(request, expiredCode)).json()).toMatchObject({ status: 'expired' }); + expect((await queryResult(request, expiredCode)).status()).toBe(400); + } finally { + if (expiredResult?.jwt) await deleteAddress(request, expiredResult.jwt); + await deleteCode(request, 'address_prefix_once', expiredCode); + } + }); +}); diff --git a/e2e/tests/browser/redeem-code.spec.ts b/e2e/tests/browser/redeem-code.spec.ts new file mode 100644 index 0000000..6529f52 --- /dev/null +++ b/e2e/tests/browser/redeem-code.spec.ts @@ -0,0 +1,387 @@ +import { createHash } from 'crypto'; +import { expect, test, type APIRequestContext } from '@playwright/test'; +import { + createTestAddress, + deleteAddress, + FRONTEND_URL, + FRONTEND_URL_ENV_OFF, + WORKER_URL, +} from '../../fixtures/test-helpers'; + +const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' }; + +const uniqueValue = (label: string) => ( + `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +); + +async function createCode( + request: APIRequestContext, + type: 'role' | 'send_balance' | 'address_prefix_once', + value: string, +) { + const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, { + headers: ADMIN_HEADERS, + data: { + count: 1, + redeem_type: type, + value, + enabled: true, + expires_at: new Date(Date.now() + 3_600_000).toISOString(), + }, + }); + expect(response.ok()).toBe(true); + const body = await response.json(); + expect(body.codes).toHaveLength(1); + return body.codes[0] as string; +} + +async function findCode(request: APIRequestContext, type: string, code: string) { + const response = await request.get( + `${WORKER_URL}/admin/redeem_codes?redeem_type=${type}` + + `&limit=20&offset=0&query=${encodeURIComponent(code)}`, + { headers: ADMIN_HEADERS }, + ); + const body = await response.json(); + return body.results.find((row: { code: string }) => row.code === code); +} + +async function deleteCode(request: APIRequestContext, type: string, code: string) { + const row = await findCode(request, type, code); + if (!row) return; + await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, { + headers: ADMIN_HEADERS, + }); +} + +async function createUser(request: APIRequestContext) { + const email = `${uniqueValue('browser-redeem-user')}@test.example.com`; + const password = createHash('sha256').update('browser-redeem-password').digest('hex'); + const response = await request.post(`${WORKER_URL}/admin/users`, { + headers: ADMIN_HEADERS, + data: { email, password }, + }); + expect(response.ok()).toBe(true); + const usersResponse = await request.get( + `${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`, + { headers: ADMIN_HEADERS }, + ); + const user = (await usersResponse.json()).results[0]; + await request.post(`${WORKER_URL}/admin/user_roles`, { + headers: ADMIN_HEADERS, + data: { user_id: user.id, role_text: null }, + }); + const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, { + data: { email, password }, + }); + expect(loginResponse.ok()).toBe(true); + return { id: user.id as number, email, jwt: (await loginResponse.json()).jwt as string }; +} + +async function deleteUser(request: APIRequestContext, userId: number) { + await request.post(`${WORKER_URL}/admin/user_roles`, { + headers: ADMIN_HEADERS, + data: { user_id: userId, role_text: null }, + }); + await request.delete(`${WORKER_URL}/admin/users/${userId}`, { headers: ADMIN_HEADERS }); +} + +test('the mailbox entry opens the pure redemption page and completes role redemption', async ({ page, request }) => { + const user = await createUser(request); + const code = await createCode(request, 'role', 'case-role'); + try { + await page.goto(`${FRONTEND_URL}/en/`); + await expect(page.getByTestId('redeem-entry')).toBeVisible(); + await page.getByTestId('redeem-entry').click(); + await expect(page).toHaveURL(/\/en\/redeem$/); + await expect(page.getByTestId('redeem-code-link')).toHaveAttribute( + 'href', 'https://example.com/redeem-codes', + ); + await page.evaluate((jwt) => localStorage.setItem('userJwt', jwt), user.jwt); + const settingsResponse = page.waitForResponse((response) => ( + new URL(response.url()).pathname === '/user_api/settings' + )); + await page.reload(); + await settingsResponse; + + await page.getByTestId('redeem-code-input').locator('input').fill(code); + await page.getByRole('button', { name: 'Look up code' }).click(); + await expect(page.getByRole('heading', { name: 'Role benefits' })).toBeVisible(); + await expect(page.getByTestId('redeem-user-email')).toHaveCount(0); + await page.getByTestId('redeem-now').click(); + const userEmailInput = page.getByTestId('redeem-user-email').locator('input'); + await expect(userEmailInput).toHaveValue(user.email); + await userEmailInput.fill(user.email.toUpperCase()); + const refreshedSettings = page.waitForResponse((response) => ( + new URL(response.url()).pathname === '/user_api/settings' && response.ok() + )); + await page.getByRole('button', { name: 'Confirm redemption' }).click(); + const refreshedUser = await (await refreshedSettings).json(); + expect(refreshedUser.user_role.role).toBe('case-role'); + expect(refreshedUser.access_token).toEqual(expect.any(String)); + await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible(); + await expect(page.getByText(user.email, { exact: false })).toBeVisible(); + await expect(page.getByText('case-role', { exact: false })).toBeVisible(); + + const row = await findCode(request, 'role', code); + expect(JSON.parse(row.result)).toMatchObject({ user_email: user.email, role: 'case-role' }); + await page.getByRole('button', { name: 'Redeem another code' }).click(); + await page.getByTestId('redeem-code-input').locator('input').fill(code); + const nextQuery = page.waitForRequest((request) => ( + new URL(request.url()).pathname === '/redeem_api/query' + )); + await page.getByRole('button', { name: 'Look up code' }).click(); + expect((await nextQuery).headers()['x-user-access-token']).toBe(refreshedUser.access_token); + } finally { + await deleteCode(request, 'role', code); + await deleteUser(request, user.id); + } +}); + +test('sending credits defaults to the current mailbox and keeps the target editable', async ({ page, request }) => { + const address = await createTestAddress(request, 'rdb-'); + const code = await createCode(request, 'send_balance', '3'); + try { + await page.goto(`${FRONTEND_URL}/en/`); + await expect(page.getByTestId('redeem-entry')).toBeVisible(); + await page.getByTestId('redeem-entry').click(); + await expect(page).toHaveURL(/\/en\/redeem$/); + await page.evaluate((jwt) => localStorage.setItem('jwt', jwt), address.jwt); + const settingsResponse = page.waitForResponse((response) => ( + new URL(response.url()).pathname === '/api/settings' + )); + await page.reload(); + await settingsResponse; + + await page.getByTestId('redeem-code-input').locator('input').fill(code); + await page.getByRole('button', { name: 'Look up code' }).click(); + await page.getByTestId('redeem-now').click(); + const targetInput = page.getByTestId('redeem-target-address').locator('input'); + await expect(targetInput).toHaveValue(address.address); + await targetInput.fill(address.address.toUpperCase()); + await page.getByRole('button', { name: 'Confirm redemption' }).click(); + await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible(); + } finally { + await deleteCode(request, 'send_balance', code); + await deleteAddress(request, address.jwt); + } +}); + +test('the special-address page shows and retrieves the same full credentials', async ({ page, request }) => { + const code = await createCode(request, 'address_prefix_once', 'ui'); + const name = `m${Math.random().toString(36).slice(2, 8)}`; + let jwt = ''; + try { + await page.goto(`${FRONTEND_URL}/en/redeem`); + await page.getByTestId('redeem-code-input').locator('input').fill(code); + await page.getByRole('button', { name: 'Look up code' }).click(); + await expect(page.getByRole('heading', { name: 'Custom mailbox' })).toBeVisible(); + await expect(page.getByText('Unused', { exact: true })).toBeVisible(); + await page.getByTestId('redeem-address-name').locator('input').fill(name); + await page.getByRole('button', { name: 'Redeem mailbox' }).click(); + + const addressValue = page.getByTestId('address-credential-address'); + const passwordValue = page.getByTestId('address-credential-password'); + const jwtValue = page.getByTestId('address-credential-jwt'); + await expect(addressValue).toHaveText(`ui${name}@test.example.com`); + const firstAddress = await addressValue.innerText(); + const firstPassword = await passwordValue.innerText(); + const firstJwt = await jwtValue.innerText(); + expect(firstPassword).not.toBe(''); + expect(firstJwt).not.toBe(''); + jwt = firstJwt; + + await page.getByRole('button', { name: 'Redeem another code' }).click(); + await page.getByTestId('redeem-code-input').locator('input').fill(code); + await page.getByRole('button', { name: 'Look up code' }).click(); + await expect(page.getByText('Used', { exact: true })).toBeVisible(); + await expect(page.getByText(firstAddress, { exact: false })).toHaveCount(0); + await page.getByRole('button', { name: 'View redemption result' }).click(); + await expect(page.getByTestId('address-credential-address')).toHaveText(firstAddress); + await expect(page.getByTestId('address-credential-password')).toHaveText(firstPassword); + await expect(page.getByTestId('address-credential-jwt')).toHaveText(firstJwt); + } finally { + if (jwt) { + await request.delete(`${WORKER_URL}/api/delete_address`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + } + await deleteCode(request, 'address_prefix_once', code); + } +}); + +test('Admin can batch-create, search, export, and display a redemption result', async ({ page, request }) => { + let code = ''; + const user = await createUser(request); + try { + await page.addInitScript(() => { + localStorage.setItem('adminAuth', 'e2e-admin-pass'); + sessionStorage.setItem('adminTab', 'redeemCodes'); + }); + await page.goto(`${FRONTEND_URL}/en/admin`); + await expect(page.getByTestId('redeem-admin-create')).toBeVisible(); + await page.getByTestId('redeem-admin-create').click(); + + const dialog = page.getByRole('dialog'); + await dialog.getByTestId('redeem-admin-count').locator('input').fill('1'); + await dialog.getByTestId('redeem-admin-role').click(); + await page.locator('.n-base-select-option').filter({ hasText: 'case-role' }).click(); + await dialog.getByRole('button', { name: 'Generate' }).click(); + await expect(page.getByText('Select a future expiration time')).toBeVisible(); + const expirationInput = dialog.locator('.n-date-picker input'); + await expirationInput.fill('2099-01-01 00:00:00'); + await expirationInput.press('Enter'); + const createResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname === '/admin/redeem_codes/batch' + )); + const createdCodesDownload = page.waitForEvent('download'); + await dialog.getByRole('button', { name: 'Generate' }).click(); + const createResponse = await createResponsePromise; + code = (await createResponse.json()).codes[0]; + expect((await createdCodesDownload).suggestedFilename()).toBe('redeem-codes-role.csv'); + await expect(page.getByText('Redemption codes generated: 1.')).toBeVisible(); + + await page.getByTestId('redeem-admin-search').locator('input').fill(code); + await page.getByRole('button', { name: 'Search' }).click(); + await expect(page.getByText(code, { exact: true })).toBeVisible(); + + const exportDownload = page.waitForEvent('download'); + await page.getByTestId('redeem-admin-export').click(); + await expect(page.getByText('Export rows (maximum 10000)')).toBeVisible(); + await page.getByRole('dialog').getByRole('button', { name: 'Download CSV' }).click(); + expect((await exportDownload).suggestedFilename()).toBe('redeem-codes-role.csv'); + + const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, { + data: { code, user_email: user.email }, + }); + expect(redeemResponse.ok()).toBe(true); + await page.getByRole('button', { name: 'Search' }).click(); + await expect(page.getByText(user.email, { exact: true })).toBeVisible(); + } finally { + if (code) await deleteCode(request, 'role', code); + await deleteUser(request, user.id); + } +}); + +test('Admin ignores a stale list response after switching redemption type', async ({ page }) => { + let releaseRole!: () => void; + let markRoleRequested!: () => void; + let markRoleCompleted!: () => void; + const roleGate = new Promise((resolve) => { releaseRole = resolve; }); + const roleRequested = new Promise((resolve) => { markRoleRequested = resolve; }); + const roleCompleted = new Promise((resolve) => { markRoleCompleted = resolve; }); + + await page.route('**/admin/redeem_codes?*', async (route) => { + const type = new URL(route.request().url()).searchParams.get('redeem_type'); + if (type === 'role') { + markRoleRequested(); + await roleGate; + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + count: 1, + results: [{ + id: 1, + code: 'STALE-ROLE-CODE', + redeem_type: 'role', + value: 'case-role', + result: null, + enabled: 1, + expires_at: '2099-01-01 00:00:00', + redeemed_at: null, + }], + }), + }); + markRoleCompleted(); + return; + } + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + count: 1, + results: [{ + id: 2, + code: 'CURRENT-BALANCE-CODE', + redeem_type: 'send_balance', + value: '20', + result: null, + enabled: 1, + expires_at: '2099-01-01 00:00:00', + redeemed_at: null, + }], + }), + }); + }); + await page.addInitScript(() => { + localStorage.setItem('adminAuth', 'e2e-admin-pass'); + sessionStorage.setItem('adminTab', 'redeemCodes'); + }); + await page.goto(`${FRONTEND_URL}/en/admin`); + await roleRequested; + + await page.getByTestId('redeem-admin-filter-type').click(); + await page.locator('.n-base-select-option').filter({ hasText: 'Sending credits' }).click(); + await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible(); + + releaseRole(); + await roleCompleted; + await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible(); + await expect(page.getByText('STALE-ROLE-CODE', { exact: true })).toHaveCount(0); +}); + +for (const { locale, roleRequired, roleType, addressType, create, generate, prefixError } of [ + { + locale: 'en', roleRequired: 'Select a role', roleType: 'Role benefits', + addressType: 'Custom mailbox', create: 'Batch Generate', generate: 'Generate', + prefixError: 'Use only letters and digits, up to 29 characters. Leave empty for no prefix.', + }, + { + locale: 'zh', roleRequired: '请选择角色', roleType: '角色权益', + addressType: '专属邮箱', create: '批量生成', generate: '生成', + prefixError: '前缀仅支持英文字母和数字,最多 29 个字符;留空表示无前缀。', + }, +]) { + test(`Admin validates missing role and invalid prefix in ${locale}`, async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('adminAuth', 'e2e-admin-pass'); + sessionStorage.setItem('adminTab', 'redeemCodes'); + }); + let createRequests = 0; + page.on('request', (request) => { + if (request.method() === 'POST' + && new URL(request.url()).pathname === '/admin/redeem_codes/batch') { + createRequests++; + } + }); + await page.goto(`${FRONTEND_URL}/${locale}/admin`); + await expect(page.getByTestId('redeem-admin-filter-type')).toContainText(roleType); + await page.getByRole('button', { name: create, exact: true }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: generate, exact: true }).click(); + await expect(page.getByText(roleRequired, { exact: true })).toBeVisible(); + await dialog.getByRole('button', { name: 'close', exact: true }).click(); + await page.getByTestId('redeem-admin-filter-type').click(); + await page.locator('.n-base-select-option').filter({ hasText: addressType }).click(); + await page.getByRole('button', { name: create, exact: true }).click(); + await dialog.locator('input[maxlength]').fill('bad-'); + await dialog.getByRole('button', { name: generate, exact: true }).click(); + await expect(page.getByText(prefixError, { exact: true })).toBeVisible(); + expect(createRequests).toBe(0); + }); +} + +test('the disabled frontend hides the entry and redirects the page', async ({ page }) => { + await page.goto(`${FRONTEND_URL_ENV_OFF}/en/`); + await expect(page.getByTestId('redeem-entry')).toHaveCount(0); + + await page.goto(`${FRONTEND_URL_ENV_OFF}/en/redeem`); + await expect(page).not.toHaveURL(/\/redeem$/); + await expect(page.getByTestId('redeem-entry')).toHaveCount(0); + + await page.addInitScript(() => { + localStorage.setItem('adminAuth', 'e2e-admin-pass'); + }); + await page.goto(`${FRONTEND_URL_ENV_OFF}/en/admin`); + await expect(page.getByTestId('redeem-admin-create')).toHaveCount(0); + await expect(page.getByText('Redemption Codes', { exact: true })).toHaveCount(0); +}); diff --git a/e2e/tests/unit/mailpit-events.test.mjs b/e2e/tests/unit/mailpit-events.test.mjs new file mode 100644 index 0000000..4f930ab --- /dev/null +++ b/e2e/tests/unit/mailpit-events.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { after, before, test } from 'node:test'; +import { WebSocketServer } from 'ws'; + +let server; +let onMailpitMessage; + +before(async () => { + server = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + await once(server, 'listening'); + const originalApi = process.env.MAILPIT_API; + process.env.MAILPIT_API = `http://127.0.0.1:${server.address().port}/api`; + try { + ({ onMailpitMessage } = await import('../../fixtures/test-helpers.ts')); + } finally { + if (originalApi === undefined) delete process.env.MAILPIT_API; + else process.env.MAILPIT_API = originalApi; + } +}); + +after(async () => { + for (const client of server.clients) client.terminate(); + await new Promise((resolve) => server.close(resolve)); +}); + +const target = { Subject: 'target-message' }; +const newEvent = JSON.stringify({ Type: 'new', Data: target }); +const statsEvent = JSON.stringify({ Type: 'stats', Data: { total: 1 } }); + +for (const [name, payload] of [ + ['single event', newEvent], + ['new event followed by stats', `${newEvent}\n${statsEvent}`], + ['stats followed by new event', `${statsEvent}\n${newEvent}`], + ['malformed and unrelated events before the match', + `invalid-json\n${JSON.stringify({ Type: 'new', Data: { Subject: 'other' } })}\n${newEvent}\n`], +]) { + test(name, async () => { + const connected = once(server, 'connection'); + const listener = onMailpitMessage((mail) => mail.Subject === target.Subject, { timeout: 1000 }); + const received = assert.doesNotReject(async () => { + assert.deepEqual(await listener.message, target); + }); + const [client] = await connected; + await listener.ready; + client.send(payload); + await received; + }); +} diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 30b481b..51bef3f 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -111,6 +111,8 @@ const getOpenSettings = async (message, notification) => { showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser, enableAddressPassword: res["enableAddressPassword"] || false, enableAgentEmailInfo: res["enableAgentEmailInfo"] || false, + enableRedeemCode: res["enableRedeemCode"] || false, + redeemCodeUrl: res["redeemCodeUrl"] || "", smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig, statusUrl: res["statusUrl"] || "", enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false, diff --git a/frontend/src/components/AddressCredentialContent.vue b/frontend/src/components/AddressCredentialContent.vue new file mode 100644 index 0000000..4770c93 --- /dev/null +++ b/frontend/src/components/AddressCredentialContent.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/frontend/src/components/AddressCredentialModal.vue b/frontend/src/components/AddressCredentialModal.vue index 8f13302..019ea31 100644 --- a/frontend/src/components/AddressCredentialModal.vue +++ b/frontend/src/components/AddressCredentialModal.vue @@ -1,9 +1,9 @@ - - diff --git a/frontend/src/i18n/message-registry.ts b/frontend/src/i18n/message-registry.ts index eaf7206..1378dff 100644 --- a/frontend/src/i18n/message-registry.ts +++ b/frontend/src/i18n/message-registry.ts @@ -307,8 +307,8 @@ export const MESSAGE_REGISTRY = { "zh": "地址凭证" }, "addressCredentialLabel": { - "en": "Address JWT", - "zh": "Address JWT" + "en": "Mailbox access credential", + "zh": "邮箱访问凭证" }, "addressPassword": { "en": "Mailbox Password", @@ -351,8 +351,8 @@ export const MESSAGE_REGISTRY = { "zh": "已复制" }, "currentAddress": { - "en": "Current Email Address", - "zh": "当前邮箱地址" + "en": "Email address", + "zh": "邮箱地址" }, "docs": { "en": "Docs", @@ -692,6 +692,10 @@ export const MESSAGE_REGISTRY = { "en": "Quick Setup", "zh": "快速设置" }, + "redeemCodes": { + "en": "Redemption Codes", + "zh": "兑换码" + }, "roleAddressConfig": { "en": "Role Address Config", "zh": "角色地址配置" @@ -745,6 +749,180 @@ export const MESSAGE_REGISTRY = { "zh": "Worker 配置" } }, + "views.admin.RedeemCodes": { + "actions": { + "en": "Actions", + "zh": "操作" + }, + "amount": { + "en": "Sending credits", + "zh": "发信额度" + }, + "batchCreate": { + "en": "Batch Generate", + "zh": "批量生成" + }, + "countLimit": { + "en": "Generate 1-{max} codes at a time.", + "zh": "每次可生成 1-{max} 个兑换码。" + }, + "createdResult": { + "en": "Redemption codes generated: {created}.", + "zh": "已生成兑换码:{created} 个。" + }, + "copy": { + "en": "Copy", + "zh": "复制" + }, + "copied": { + "en": "Redemption code copied", + "zh": "兑换码已复制" + }, + "copyFailed": { + "en": "Unable to copy the redemption code", + "zh": "复制兑换码失败" + }, + "delete": { + "en": "Delete", + "zh": "删除" + }, + "deleteConfirm": { + "en": "Delete this redemption code?", + "zh": "确定删除这个兑换码吗?" + }, + "downloadCsv": { + "en": "Download CSV", + "zh": "下载 CSV" + }, + "edit": { + "en": "Edit", + "zh": "编辑" + }, + "emptyPrefix": { + "en": "No prefix", + "zh": "无前缀" + }, + "emptyPrefixTip": { + "en": "Leave empty to redeem a mailbox without a prefix", + "zh": "留空即兑换无前缀邮箱地址" + }, + "enabled": { + "en": "Enabled", + "zh": "启用" + }, + "expiresAt": { + "en": "Expires at", + "zh": "有效期至" + }, + "expired": { + "en": "Expired", + "zh": "已过期" + }, + "export": { + "en": "Export", + "zh": "导出" + }, + "exportLimit": { + "en": "Export rows (maximum {max})", + "zh": "导出条数(上限 {max})" + }, + "generationCount": { + "en": "Number to generate", + "zh": "生成数量" + }, + "generate": { + "en": "Generate", + "zh": "生成" + }, + "invalidExpiration": { + "en": "Select a future expiration time", + "zh": "请选择未来的有效期" + }, + "invalidPrefix": { + "en": "Use only letters and digits, up to {max} characters. Leave empty for no prefix.", + "zh": "前缀仅支持英文字母和数字,最多 {max} 个字符;留空表示无前缀。" + }, + "roleRequired": { + "en": "Select a role", + "zh": "请选择角色" + }, + "code": { + "en": "Redemption code", + "zh": "兑换码" + }, + "no": { + "en": "No", + "zh": "否" + }, + "prefix": { + "en": "Address prefix", + "zh": "地址前缀" + }, + "redeemedAt": { + "en": "Redeemed at", + "zh": "兑换时间" + }, + "redeemedUser": { + "en": "Recipient account", + "zh": "兑换账号" + }, + "resultAddress": { + "en": "Redeemed mailbox", + "zh": "已兑换邮箱" + }, + "role": { + "en": "Role", + "zh": "角色" + }, + "save": { + "en": "Save", + "zh": "保存" + }, + "saved": { + "en": "Saved", + "zh": "保存成功" + }, + "search": { + "en": "Search", + "zh": "查询" + }, + "searchPlaceholder": { + "en": "Search redemption code", + "zh": "搜索兑换码" + }, + "targetAddress": { + "en": "Recipient mailbox", + "zh": "兑换邮箱" + }, + "type": { + "en": "Type", + "zh": "类型" + }, + "typeFilter": { + "en": "Redemption code type", + "zh": "兑换码类型" + }, + "typeAddress": { + "en": "Custom mailbox", + "zh": "专属邮箱" + }, + "typeBalance": { + "en": "Sending credits", + "zh": "发信额度" + }, + "typeRole": { + "en": "Role benefits", + "zh": "角色权益" + }, + "valid": { + "en": "Active", + "zh": "有效" + }, + "yes": { + "en": "Yes", + "zh": "是" + } + }, "views.User": { "address_management": { "en": "Mailbox Management", @@ -2680,6 +2858,14 @@ export const MESSAGE_REGISTRY = { "en": "Create Email Address", "zh": "创建邮箱" }, + "useRedeemCode": { + "en": "Use Redemption Code", + "zh": "使用兑换码" + }, + "redeemEntryTip": { + "en": "Use a redemption code to unlock advanced access.", + "zh": "使用兑换码可解锁高级权限" + }, "normalSubdomain": { "en": "Normal Domain", "zh": "普通域名" @@ -2729,6 +2915,164 @@ export const MESSAGE_REGISTRY = { "zh": "启用后,创建出来的地址会自动挂在随机子域名下,建议仅用于收件。需要在基础域名 DNS 中配置通配 MX 记录,详见随机子域名文档。" } }, + "views.Redeem": { + "address": { + "en": "Email address", + "zh": "邮箱地址" + }, + "addressCredential": { + "en": "Mailbox access credential", + "zh": "邮箱访问凭证" + }, + "addressPassword": { + "en": "Address password", + "zh": "邮箱密码" + }, + "addressSuccess": { + "en": "Redemption complete. Keep the mailbox credentials below secure.", + "zh": "兑换成功,请妥善保存以下邮箱凭证。" + }, + "autoName": { + "en": "Generated automatically", + "zh": "自动生成" + }, + "back": { + "en": "Back to mailbox", + "zh": "返回邮箱" + }, + "balanceDescription": { + "en": "Redeem {amount} sending credits for a mailbox.", + "zh": "为指定邮箱兑换 {amount} 次发信额度。" + }, + "balanceSuccess": { + "en": "Added {amount} sending credits to {address}.", + "zh": "已为 {address} 增加 {amount} 次发信额度。" + }, + "balanceTitle": { + "en": "Sending credits", + "zh": "发信额度" + }, + "checkCode": { + "en": "Look up code", + "zh": "查询兑换码" + }, + "codeLabel": { + "en": "Redemption code", + "zh": "兑换码" + }, + "codePlaceholder": { + "en": "Enter your code", + "zh": "输入你的兑换码" + }, + "categoryTitle": { + "en": "Available benefits", + "zh": "可兑换权益" + }, + "confirmBalance": { + "en": "Confirm redemption", + "zh": "确认兑换" + }, + "confirmRole": { + "en": "Confirm redemption", + "zh": "确认兑换" + }, + "createAddress": { + "en": "Redeem mailbox", + "zh": "兑换邮箱地址" + }, + "customSubdomain": { + "en": "Custom subdomain", + "zh": "自定义子域名" + }, + "expired": { + "en": "Expired", + "zh": "已过期" + }, + "getCode": { + "en": "Get a redemption code", + "zh": "获取兑换码" + }, + "noPrefixDescription": { + "en": "Redeem one mailbox without a prefix.", + "zh": "可兑换 1 个无前缀邮箱地址。" + }, + "normalDomain": { + "en": "Normal domain", + "zh": "普通域名" + }, + "openAddress": { + "en": "Open mailbox", + "zh": "打开邮箱" + }, + "prefixDescription": { + "en": "Redeem one mailbox beginning with {prefix}.", + "zh": "可兑换 1 个以 {prefix} 开头的邮箱地址。" + }, + "prefixTitle": { + "en": "Custom mailbox", + "zh": "专属邮箱" + }, + "randomName": { + "en": "Generate name", + "zh": "随机生成" + }, + "randomSubdomain": { + "en": "Random subdomain", + "zh": "随机子域名" + }, + "redeemAnother": { + "en": "Redeem another code", + "zh": "继续兑换" + }, + "redeemed": { + "en": "Used", + "zh": "已使用" + }, + "redeemNow": { + "en": "Redeem now", + "zh": "立即兑换" + }, + "roleDescription": { + "en": "Grant the {role} benefits to a specified account.", + "zh": "为指定账号开通 {role} 角色权益。" + }, + "roleSuccess": { + "en": "The {role} benefits are now active for {email}.", + "zh": "已为 {email} 开通 {role} 角色权益。" + }, + "roleTitle": { + "en": "Role benefits", + "zh": "角色权益" + }, + "targetAddress": { + "en": "Target email address", + "zh": "目标邮箱地址" + }, + "unused": { + "en": "Unused", + "zh": "未使用" + }, + "success": { + "en": "Redemption complete", + "zh": "兑换成功" + }, + "title": { + "en": "Redeem benefits", + "zh": "兑换权益" + }, + "userEmail": { + "en": "Recipient account", + "zh": "接收账号" + }, + "viewResult": { + "en": "View redemption result", + "zh": "查看兑换结果" + }, + "userEmailPlaceholder": { + "en": "Enter the account email", + "zh": "请输入账号邮箱" + } + }, "views.admin.Webhook": { "enableAllowList": { "en": "Enable Allow List (Restrict webhook access to specific users)", diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index d10d694..d93dd63 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -27,6 +27,11 @@ const router = createRouter({ alias: '/:lang/user', component: User }, + { + path: '/redeem', + alias: '/:lang/redeem', + component: () => import('../views/Redeem.vue') + }, { path: '/user/oauth2/callback', alias: '/:lang/user/oauth2/callback', diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 518ff89..9aa2c9b 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -43,6 +43,8 @@ export const useGlobalState = createGlobalState( disableAdminPasswordCheck: false, enableAddressPassword: false, enableAgentEmailInfo: false, + enableRedeemCode: false, + redeemCodeUrl: '', smtpImapProxyConfig: { smtp: { host: '', diff --git a/frontend/src/utils/composables.js b/frontend/src/utils/composables.js index 85c3184..ba42bb9 100644 --- a/frontend/src/utils/composables.js +++ b/frontend/src/utils/composables.js @@ -1,7 +1,7 @@ import { useBreakpoint, useMemo } from 'vooks' export function useIsMobile() { - const breakpointRef = useBreakpoint() + const breakpointRef = useBreakpoint({ xs: 0, s: 768 }) return useMemo(() => { return breakpointRef.value === 'xs' }) diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue index 495009e..1c7f2bb 100644 --- a/frontend/src/views/Admin.vue +++ b/frontend/src/views/Admin.vue @@ -30,6 +30,7 @@ import MailWebhook from './admin/MailWebhook.vue'; import WorkerConfig from './admin/WorkerConfig.vue'; import IpBlacklistSettings from './admin/IpBlacklistSettings.vue'; import AiExtractSettings from './admin/AiExtractSettings.vue'; +import RedeemCodes from './admin/RedeemCodes.vue'; const { adminAuth, showAdminAuth, adminTab, loading, @@ -201,6 +202,9 @@ onMounted(async () => { + + + diff --git a/frontend/src/views/Header.vue b/frontend/src/views/Header.vue index 012ca00..d55d312 100644 --- a/frontend/src/views/Header.vue +++ b/frontend/src/views/Header.vue @@ -331,6 +331,32 @@ onMounted(async () => { diff --git a/frontend/src/views/Redeem.vue b/frontend/src/views/Redeem.vue new file mode 100644 index 0000000..60bca40 --- /dev/null +++ b/frontend/src/views/Redeem.vue @@ -0,0 +1,627 @@ + + + + + diff --git a/frontend/src/views/admin/RedeemCodes.vue b/frontend/src/views/admin/RedeemCodes.vue new file mode 100644 index 0000000..dc106fd --- /dev/null +++ b/frontend/src/views/admin/RedeemCodes.vue @@ -0,0 +1,493 @@ + + + + + diff --git a/frontend/src/views/common/Login.vue b/frontend/src/views/common/Login.vue index 3e991f7..3ccf95f 100644 --- a/frontend/src/views/common/Login.vue +++ b/frontend/src/views/common/Login.vue @@ -2,7 +2,7 @@ import { ref, onMounted, computed, watch } from 'vue' import { useScopedI18n } from '@/i18n/app' import { useRouter } from 'vue-router' -import { NewLabelOutlined, EmailOutlined } from '@vicons/material' +import { NewLabelOutlined, EmailOutlined, RedeemOutlined } from '@vicons/material' import AdminContact from '../common/AdminContact.vue' import Turnstile from '../../components/Turnstile.vue' @@ -192,6 +192,10 @@ const newEmail = async () => { } }; +const openRedeemPage = async () => { + await router.push(getRouterPathWithLang('/redeem', locale.value)); +}; + const addressPrefix = computed(() => { // if user has role, return role prefix if (userSettings.value?.user_role) { @@ -298,6 +302,16 @@ onMounted(async () => { {{ t('getNewEmail') }} +
+ + {{ t('redeemEntryTip') }} + + + {{ t('useRedeemCode') }} + +
@@ -350,6 +364,17 @@ onMounted(async () => { {{ t('getNewEmail') }} +
+ + {{ t('redeemEntryTip') }} + + + {{ t('useRedeemCode') }} + +
@@ -375,6 +400,21 @@ onMounted(async () => { margin-top: 10px; } +.redeem-entry-section { + margin-top: 18px; +} + +.redeem-entry-section :deep(.n-divider) { + margin: 0 0 14px; +} + +.redeem-entry-tip { + display: block; + font-size: 13px; + line-height: 1.6; + text-align: center; +} + .switch-login-button { display: flex; justify-content: center; diff --git a/pages/functions/_middleware.js b/pages/functions/_middleware.js index a95df43..ea89f5d 100644 --- a/pages/functions/_middleware.js +++ b/pages/functions/_middleware.js @@ -5,6 +5,7 @@ const API_PATHS = [ "/admin/", "/telegram/", "/external/", + "/redeem_api/", ]; export async function onRequest(context) { diff --git a/vitepress-docs/docs/.vitepress/en.ts b/vitepress-docs/docs/.vitepress/en.ts index 370ee78..36658dd 100644 --- a/vitepress-docs/docs/.vitepress/en.ts +++ b/vitepress-docs/docs/.vitepress/en.ts @@ -170,6 +170,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] { { text: 'Configure S3 Attachments', link: 'feature/s3-attachment' }, { text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' }, { text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' }, + { text: 'Configure Redemption Codes', link: 'feature/redeem-code' }, { text: 'Add Google Ads', link: 'feature/google-ads.md' }, ] }, diff --git a/vitepress-docs/docs/.vitepress/zh.ts b/vitepress-docs/docs/.vitepress/zh.ts index 7ba6c16..212077c 100644 --- a/vitepress-docs/docs/.vitepress/zh.ts +++ b/vitepress-docs/docs/.vitepress/zh.ts @@ -170,6 +170,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] { { text: '配置 S3 附件', link: 'feature/s3-attachment' }, { text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' }, { text: '配置其他 worker 增强', link: 'feature/another-worker-enhanced' }, + { text: '配置兑换码', link: 'feature/redeem-code' }, { text: '给网页增加 Google Ads', link: 'feature/google-ads.md' }, ] }, diff --git a/vitepress-docs/docs/en/guide/feature/redeem-code.md b/vitepress-docs/docs/en/guide/feature/redeem-code.md new file mode 100644 index 0000000..efad824 --- /dev/null +++ b/vitepress-docs/docs/en/guide/feature/redeem-code.md @@ -0,0 +1,64 @@ +# Redemption Codes + +Set `ENABLE_REDEEM_CODE = true` to enable the standalone `/redeem` page for code validation and benefit fulfillment. Payment, orders, inventory, and card issuing stay outside this project. Codes generated in Admin can be exported to an external sales platform. When disabled, the page entry, Admin menu, and related APIs are unavailable. + +Optionally set `REDEEM_CODE_URL` to the external page where users obtain codes. The redemption page hides this link when the variable is unset. + +## Database + +Run the database migration from the Admin database page after upgrading. The migration files and `db/schema.sql` are the source of truth for the table structure. + +When Admin creates codes, the Worker uses `crypto.randomUUID()` to generate UUID v4 codes without a fixed prefix. `code` stores the case-sensitive plaintext redemption code so Admin exports can contain usable codes. `redeem_type` selects the business handler, and `value` is a plain string parsed by that handler. The system maintains `redeemed`, `result`, and the redemption timestamp. `result` stores the JSON redemption result encrypted with AES-256-GCM using a key derived from `JWT_SECRET`. Every code requires a future `expires_at`; redemption and result retrieval stop after it expires. Changing `JWT_SECRET` invalidates both existing address credentials and stored redemption results. + +The external platform only sells and distributes codes exported from Admin. This project does not handle payment, orders, or inventory. + +## Supported benefits + +### Role benefits + +```text +redeem_type = role +value = vip +``` + +The role must exist in `USER_ROLES` and cannot equal `ADMIN_USER_ROLE`. Redemption applies all prefix, domain, address-count, and sending privileges configured for that role. Configure a role with `prefix: ""` for permanent prefix-free addresses. + +Redemption refuses to overwrite an existing role that differs from both the default and target roles, leaving the code unused. This check also applies to concurrent redemptions of different roles. When signed-in users redeem for themselves, the page refreshes their role configuration and access token. + +### Sending credits + +```text +redeem_type = send_balance +value = 100 +``` + +This adds to `address_sender.balance` for the target address entered on the redemption page. Mailboxes without a sender record first receive `DEFAULT_SEND_BALANCE`, then the redeemed credits; existing records do not receive default credits again. Credits belong to an email address. Redemption does not re-enable an address disabled by an administrator. + +### Custom mailbox + +```text +redeem_type = address_prefix_once +value = vip +``` + +`value` accepts lowercase letters and digits, or it may be empty, and must leave at least one character under `MAX_ADDRESS_LEN` for user input. The redemption module prepends it directly to the user's input, then creates the address with the system prefix disabled; the configured address-name regex and block list still apply. An empty value therefore creates a prefix-free address. The first redemption creates an address and writes the complete response to `result` in encrypted form. The page only displays the address and credential; it does not switch to or bind the mailbox automatically. Further redemptions before expiry return the same complete result. If temporary-address cleanup removes its row, the result is no longer available and the system does not recreate the address. + +## Admin + +The Admin redemption-code page filters by one type at a time and shows type-specific business columns. Batch generation and export both use the currently selected type. Codes in one batch share their benefit value, enabled state, and expiry, with a limit of 500 UUID v4 codes per batch. After generation, the page downloads a CSV containing only that batch. + +CSV export is also limited to one type per operation and contains usable plaintext codes. The export dialog requires a row limit, capped at 10,000. + +## API + +| Endpoint | Purpose | +| --- | --- | +| `POST /redeem_api/query` | Query a code's type, value, and status | +| `POST /redeem_api/result` | Retrieve the result of an unexpired redeemed code | +| `POST /redeem_api/redeem` | Create an address, update a role, or add sending credits | + +User-facing redemption endpoints bypass address, user, and Admin authentication; the code itself is the business credential. A configured site-access password still takes priority. The endpoints also use IP rate limiting and access controls. Role and credit business writes are atomically consumed once. The first successful special-address request fixes its complete result, which remains retrievable until expiry. + +## Adding benefit types + +To add a benefit, register a new `redeem_type` handler with strict `value` validation, a safe preview, and its business logic. The `redeem_codes` table does not need to change. diff --git a/vitepress-docs/docs/en/guide/worker-vars.md b/vitepress-docs/docs/en/guide/worker-vars.md index 08bee26..990b87d 100644 --- a/vitepress-docs/docs/en/guide/worker-vars.md +++ b/vitepress-docs/docs/en/guide/worker-vars.md @@ -13,6 +13,8 @@ | `ENABLE_USER_CREATE_EMAIL` | Text/JSON | Whether to allow users to create mailboxes, disabled if not configured | `true` | | `ENABLE_USER_DELETE_EMAIL` | Text/JSON | Whether to allow users to delete emails, disabled if not configured | `true` | | `ENABLE_MAIL_READ_STATUS` | Text/JSON | Enables read/unread mail state. Upgrade the database schema before enabling | `true` | +| `ENABLE_REDEEM_CODE` | Text/JSON | Enables the redemption page, public redemption APIs, and Admin redemption management; disabled by default | `true` | +| `REDEEM_CODE_URL` | Text | External URL for obtaining redemption codes; the link is hidden when unset | `https://example.com/redeem-codes` | > [!IMPORTANT] `DOMAINS` and `DEFAULT_DOMAINS` must already be set up in Cloudflare > Every domain you put here (including `DEFAULT_DOMAINS`, `USER_ROLES.domains`, `RANDOM_SUBDOMAIN_DOMAINS` further below) **must already have Cloudflare Email Routing enabled and its email DNS records provisioned**. After the Worker is deployed, bind the domain's Catch-all rule to that Worker; otherwise inbound mail will never reach the Worker. diff --git a/vitepress-docs/docs/zh/guide/feature/redeem-code.md b/vitepress-docs/docs/zh/guide/feature/redeem-code.md new file mode 100644 index 0000000..26ebacf --- /dev/null +++ b/vitepress-docs/docs/zh/guide/feature/redeem-code.md @@ -0,0 +1,64 @@ +# 兑换码 + +设置 `ENABLE_REDEEM_CODE = true` 后,系统提供独立的 `/redeem` 页面,用于校验兑换码和发放权益,不包含支付、订单、库存或发卡逻辑。管理端生成的兑换码可以导出到外部平台销售。未启用时页面入口、管理菜单和相关 API 均不可用。 + +可选配置 `REDEEM_CODE_URL` 作为外部获取兑换码的链接;未配置时兑换页面不会展示该入口。 + +## 数据库 + +升级后先在 Admin 数据库页面执行数据库迁移,实际表结构以项目中的迁移文件和 `db/schema.sql` 为准。 + +管理端创建时由 Worker 使用 `crypto.randomUUID()` 生成无固定前缀的 UUID v4 兑换码。`code` 保存大小写敏感的明文兑换码,便于管理端导出;`redeem_type` 用于选择业务处理器;`value` 是由该处理器解析的普通字符串。`redeemed`、`result` 和兑换时间由本系统维护,其中 `result` 使用由 `JWT_SECRET` 派生的 AES-256-GCM 密钥保存加密后的 JSON 兑换结果。每个兑换码必须设置未来的 `expires_at`,到期后不能兑换或查询兑换结果。修改 `JWT_SECRET` 会同时使已有地址凭证和兑换结果失效。 + +外部平台只负责销售和发放管理端导出的兑换码,本项目不处理支付、订单和库存流程。 + +## 支持的权益 + +### 角色权益 + +```text +redeem_type = role +value = vip +``` + +`role` 必须存在于 `USER_ROLES`,且不能是 `ADMIN_USER_ROLE`。兑换后用户获得该角色配置的前缀、域名、地址数量和发信权限。永久无前缀应通过 `prefix: ""` 的角色实现。 + +用户已有非默认且与目标不同的角色时会拒绝覆盖,兑换码不会被消费;并发兑换不同角色也执行此检查。登录用户为本人兑换后,页面自动刷新角色配置和访问令牌。 + +### 发信额度 + +```text +redeem_type = send_balance +value = 100 +``` + +给兑换页面中填写的目标邮箱增加 `address_sender.balance`。尚未初始化额度的邮箱会先按 `DEFAULT_SEND_BALANCE` 初始化,再累加兑换额度;已有记录不会重复获得默认额度。额度按邮箱地址管理;已被管理员禁用的发信地址不会因兑换自动启用。 + +### 专属邮箱 + +```text +redeem_type = address_prefix_once +value = vip +``` + +`value` 仅支持小写字母和数字,也可以为空字符串,并且必须为用户输入至少留出一个 `MAX_ADDRESS_LEN` 字符。兑换模块将它直接拼接到用户输入之前,再按不启用系统前缀的方式创建地址;地址名称仍执行站点配置的正则和黑名单校验。空字符串表示无前缀。首次兑换创建地址并将完整返回值加密写入 `result`。页面只展示地址及其访问凭证,不会自动切换或绑定邮箱。之后在有效期内重复兑换返回同一完整结果;若临时地址记录已被清理,兑换结果将不可再查询,系统不会重建该地址。 + +## 管理端 + +管理端的“兑换码”页面支持按类型筛选并显示对应业务列。“批量生成”和“导出”均使用当前筛选的类型;同一批次共用权益参数、启用状态和有效期,每次最多生成 500 个 UUID v4 兑换码,生成成功后会下载仅包含本批结果的 CSV。 + +列表可按单一类型导出包含真实兑换码的 CSV,导出弹窗必须指定条数,上限为 10,000 条。 + +## API + +| 接口 | 用途 | +| --- | --- | +| `POST /redeem_api/query` | 查询兑换码类型、值及状态 | +| `POST /redeem_api/result` | 查询未过期兑换码的兑换结果 | +| `POST /redeem_api/redeem` | 兑换邮箱地址、更新用户角色或增加发信额度 | + +用户侧兑换接口不经过地址、用户或 Admin 认证;兑换码本身即为业务凭证。如果站点设置了访问密码,站点密码仍优先应用。接口使用 IP 限流和访问控制。角色与额度业务写入和消费在同一个 D1 batch 中完成,只会成功消费一次。特殊地址码由首次成功请求固化完整结果,有效期内可重复取回。 + +## 扩展类型 + +新增权益时增加新的 `redeem_type` 处理器,并为该类型实现严格的 `value` 校验、预览和业务逻辑即可,无需修改 `redeem_codes` 表。 diff --git a/vitepress-docs/docs/zh/guide/worker-vars.md b/vitepress-docs/docs/zh/guide/worker-vars.md index 6a4ec84..ecdb0ba 100644 --- a/vitepress-docs/docs/zh/guide/worker-vars.md +++ b/vitepress-docs/docs/zh/guide/worker-vars.md @@ -13,6 +13,8 @@ | `ENABLE_USER_CREATE_EMAIL` | 文本/JSON | 是否允许用户创建邮箱, 不配置则不允许 | `true` | | `ENABLE_USER_DELETE_EMAIL` | 文本/JSON | 是否允许用户删除邮件, 不配置则不允许 | `true` | | `ENABLE_MAIL_READ_STATUS` | 文本/JSON | 启用邮件已读/未读状态。启用前需要升级数据库 Schema | `true` | +| `ENABLE_REDEEM_CODE` | 文本/JSON | 启用兑换码页面、用户兑换 API 和管理端兑换码功能;默认关闭 | `true` | +| `REDEEM_CODE_URL` | 文本 | 获取兑换码的外部平台链接;不配置则不展示入口 | `https://example.com/redeem-codes` | > [!IMPORTANT] DOMAINS 与 DEFAULT_DOMAINS 必须先在 Cloudflare 配置好 > 这里填写的所有域名(包括下文「邮箱相关变量」里的 `DEFAULT_DOMAINS`、`USER_ROLES.domains`、`RANDOM_SUBDOMAIN_DOMAINS` 等)必须是你**已经在 Cloudflare Email Routing 中启用并完成邮件 DNS 记录下发**的域名。Worker 部署完成后,还需要把该域名的 Catch-all 规则绑定到这个 Worker,否则邮件无法投递到 Worker。 diff --git a/worker/src/admin_api/db_api.ts b/worker/src/admin_api/db_api.ts index c2a2de6..9036a9b 100644 --- a/worker/src/admin_api/db_api.ts +++ b/worker/src/admin_api/db_api.ts @@ -124,6 +124,22 @@ CREATE TABLE IF NOT EXISTS user_passkeys ( CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id); + +CREATE TABLE IF NOT EXISTS redeem_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + redeem_type TEXT NOT NULL, + value TEXT NOT NULL, + result TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)), + expires_at DATETIME NOT NULL, + redeemed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type); ` export default { diff --git a/worker/src/admin_api/index.ts b/worker/src/admin_api/index.ts index 30d9a06..18cabfa 100644 --- a/worker/src/admin_api/index.ts +++ b/worker/src/admin_api/index.ts @@ -19,6 +19,7 @@ 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' export const api = new Hono() @@ -101,6 +102,15 @@ api.post('admin/db_migration', db_api.migrate) api.get('/admin/config/:key', config_api.get) api.post('/admin/config', config_api.save) +// redemption codes +api.use('/admin/redeem_codes', redeem_code_api.requireRedeemCodeEnabled) +api.use('/admin/redeem_codes/*', redeem_code_api.requireRedeemCodeEnabled) +api.get('/admin/redeem_codes', redeem_code_api.listRedeemCodes) +api.get('/admin/redeem_codes/export', redeem_code_api.exportRedeemCodes) +api.post('/admin/redeem_codes/batch', redeem_code_api.createRedeemCodes) +api.put('/admin/redeem_codes/:id', redeem_code_api.updateRedeemCode) +api.delete('/admin/redeem_codes/:id', redeem_code_api.deleteRedeemCode) + // IP blacklist settings api.get('/admin/ip_blacklist/settings', ip_blacklist_settings.getIpBlacklistSettings) api.post('/admin/ip_blacklist/settings', ip_blacklist_settings.saveIpBlacklistSettings) diff --git a/worker/src/admin_api/worker_config.ts b/worker/src/admin_api/worker_config.ts index e91b7d8..2f91ee8 100644 --- a/worker/src/admin_api/worker_config.ts +++ b/worker/src/admin_api/worker_config.ts @@ -41,6 +41,8 @@ export default { "DISABLE_ANONYMOUS_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL), "ENABLE_USER_DELETE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL), "ENABLE_MAIL_READ_STATUS": utils.getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS), + "ENABLE_REDEEM_CODE": utils.getBooleanValue(c.env.ENABLE_REDEEM_CODE), + "REDEEM_CODE_URL": utils.getStringValue(c.env.REDEEM_CODE_URL), "ENABLE_AUTO_REPLY": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY), "COPYRIGHT": c.env.COPYRIGHT, "ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK), diff --git a/worker/src/commom_api.ts b/worker/src/commom_api.ts index a62a11d..7e2933f 100644 --- a/worker/src/commom_api.ts +++ b/worker/src/commom_api.ts @@ -53,6 +53,8 @@ api.get('/open_api/settings', async (c) => { "disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK), "enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD), "enableAgentEmailInfo": utils.getBooleanValue(c.env.ENABLE_AGENT_EMAIL_INFO), + "enableRedeemCode": utils.getBooleanValue(c.env.ENABLE_REDEEM_CODE), + "redeemCodeUrl": utils.getStringValue(c.env.REDEEM_CODE_URL), "smtpImapProxyConfig": { "smtp": { "host": utils.getStringValue(smtpProxyConfig.host), diff --git a/worker/src/constants.ts b/worker/src/constants.ts index ce1cf57..45ceb1b 100644 --- a/worker/src/constants.ts +++ b/worker/src/constants.ts @@ -3,7 +3,7 @@ export const CONSTANTS = { // DB Version DB_VERSION_KEY: 'db_version', - DB_VERSION: "v0.0.8", + DB_VERSION: "v0.0.9", // DB settings ADDRESS_BLOCK_LIST_KEY: 'address_block_list', diff --git a/worker/src/i18n/en.ts b/worker/src/i18n/en.ts index 7dbc977..adb3f27 100644 --- a/worker/src/i18n/en.ts +++ b/worker/src/i18n/en.ts @@ -22,6 +22,10 @@ const messages: LocaleMessages = { InvalidAddressCredentialMsg: "Invalid address credential", UserDeleteEmailDisabledMsg: "User delete address/email is disabled, please contact the administrator", + RedeemCodeUnavailableMsg: "The redemption code is invalid, used, or expired", + RedeemCodeInvalidMsg: "Invalid redemption code configuration", + RedeemRoleConflictMsg: "The user already has a different role", + UserNotFoundMsg: "User not found", UserAlreadyExistsMsg: "User already exists, please login", FailedToRegisterMsg: "Failed to register", diff --git a/worker/src/i18n/type.ts b/worker/src/i18n/type.ts index d18e79f..bfd3a42 100644 --- a/worker/src/i18n/type.ts +++ b/worker/src/i18n/type.ts @@ -20,6 +20,10 @@ export type LocaleMessages = { InvalidAddressCredentialMsg: string UserDeleteEmailDisabledMsg: string + RedeemCodeUnavailableMsg: string + RedeemCodeInvalidMsg: string + RedeemRoleConflictMsg: string + UserNotFoundMsg: string UserAlreadyExistsMsg: string FailedToRegisterMsg: string diff --git a/worker/src/i18n/zh.ts b/worker/src/i18n/zh.ts index e4e1709..9946d41 100644 --- a/worker/src/i18n/zh.ts +++ b/worker/src/i18n/zh.ts @@ -22,6 +22,10 @@ const messages: LocaleMessages = { InvalidAddressCredentialMsg: "无效的邮箱地址凭据", UserDeleteEmailDisabledMsg: "用户删除邮箱/邮件已禁用, 请联系管理员", + RedeemCodeUnavailableMsg: "兑换码无效、已使用或已过期", + RedeemCodeInvalidMsg: "兑换码配置无效", + RedeemRoleConflictMsg: "当前用户已有其他角色,无法兑换", + UserNotFoundMsg: "用户不存在", UserAlreadyExistsMsg: "用户已存在, 请登录", FailedToRegisterMsg: "注册失败", diff --git a/worker/src/mails_api/send_balance.ts b/worker/src/mails_api/send_balance.ts index 80a9ccb..6734f5f 100644 --- a/worker/src/mails_api/send_balance.ts +++ b/worker/src/mails_api/send_balance.ts @@ -3,7 +3,7 @@ import { Context } from 'hono' import { CONSTANTS } from '../constants' import { getJsonSetting, getIntValue, getSplitStringListValue } from '../utils' -const ensureDefaultSendBalance = async ( +export const ensureDefaultSendBalance = async ( c: Context, address: string ): Promise => { diff --git a/worker/src/redeem_api/admin_redeem_code_api.ts b/worker/src/redeem_api/admin_redeem_code_api.ts new file mode 100644 index 0000000..bf951e5 --- /dev/null +++ b/worker/src/redeem_api/admin_redeem_code_api.ts @@ -0,0 +1,224 @@ +import { Context } from 'hono'; + +import { getJsonObjectValue } from '../utils'; +import { + decryptRedeemResult, + isRedeemType, + parseRedeemValue, + RedeemType, + requireRedeemCodeEnabled, + stringifyRedeemValue, +} from './redeem_code'; + +const MAX_BATCH_SIZE = 500; +const MAX_EXPORT_SIZE = 10_000; + +const parsePositiveId = (value: string): number | null => { + if (!/^\d+$/.test(value)) return null; + const id = Number(value); + return Number.isSafeInteger(id) && id > 0 ? id : null; +}; + +const validateValue = (value: unknown): value is string => ( + typeof value === 'string' && value.length <= 10_000 +); + +const normalizeExpiresAt = (expiresAt: unknown): string | undefined => { + if (typeof expiresAt !== 'string' || !expiresAt.trim()) return undefined; + const timestamp = Date.parse(expiresAt); + return Number.isFinite(timestamp) && timestamp > Date.now() + ? new Date(timestamp).toISOString() + : undefined; +}; + +const listRedeemCodes = async (c: Context) => { + const { limit: rawLimit, offset: rawOffset, query, redeem_type } = c.req.query(); + if (!isRedeemType(redeem_type)) { + return c.text('Invalid redemption code type', 400); + } + const parsedLimit = Number.parseInt(rawLimit || '20'); + const parsedOffset = Number.parseInt(rawOffset || '0'); + const limit = Number.isInteger(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 100) : 20; + const offset = Number.isInteger(parsedOffset) ? Math.max(parsedOffset, 0) : 0; + const search = query?.trim(); + const where = ` WHERE redeem_type = ?` + + (search ? ` AND code LIKE ?` : ''); + const params = search + ? [redeem_type, `%${search}%`] + : [redeem_type]; + const [rows, count] = await Promise.all([ + c.env.DB.prepare( + `SELECT id, code, redeem_type, value, result, enabled, redeemed, + expires_at, redeemed_at, created_at, updated_at + FROM redeem_codes${where} + ORDER BY id DESC LIMIT ? OFFSET ?` + ).bind(...params, limit, offset).all<{ + id: number, + redeemed: number, + result: string | null, + [key: string]: unknown, + }>(), + c.env.DB.prepare( + `SELECT count(*) AS count FROM redeem_codes${where}` + ).bind(...params).first('count'), + ]); + const results = await Promise.all(rows.results.map(async (row) => ({ + ...row, + redeemed: row.redeemed === 1, + result: await decryptRedeemResult(c, row.id, row.result), + }))); + return c.json({ results, count: count ?? 0 }); +} + +const createRedeemCodes = async (c: Context) => { + const body = await c.req.json<{ + count?: unknown, + redeem_type?: unknown, + value?: unknown, + enabled?: unknown, + expires_at?: unknown, + }>().catch(() => null); + if (!body) return c.text('Invalid redemption code data', 400); + const { count, redeem_type, value, enabled, expires_at } = body; + if (typeof count !== 'number' || !Number.isSafeInteger(count) || count < 1 || count > MAX_BATCH_SIZE + || !isRedeemType(redeem_type) + || typeof enabled !== 'boolean' + || !validateValue(value) + ) { + return c.text('Invalid redemption code data', 400); + } + const redeemValue = parseRedeemValue(c, redeem_type, value); + if (!redeemValue) return c.text('Invalid redemption code data', 400); + const expiresAt = normalizeExpiresAt(expires_at); + if (expiresAt === undefined) return c.text('Invalid expiration time', 400); + const normalizedValue = stringifyRedeemValue(redeemValue); + + const codes = Array.from({ length: count }, () => crypto.randomUUID()); + for (let index = 0; index < codes.length; index += 100) { + await c.env.DB.batch(codes.slice(index, index + 100).map((code) => ( + c.env.DB.prepare( + `INSERT INTO redeem_codes(code, redeem_type, value, enabled, expires_at) + VALUES(?, ?, ?, ?, ?)` + ).bind(code, redeem_type, normalizedValue, enabled ? 1 : 0, expiresAt) + ))); + } + return c.json({ success: true, created: codes.length, codes }); +} + +const updateRedeemCode = async (c: Context) => { + const id = parsePositiveId(c.req.param('id')); + const body = await c.req.json<{ + redeem_type?: unknown, + value?: unknown, + enabled?: unknown, + expires_at?: unknown, + }>().catch(() => null); + if (!body) return c.text('Invalid redemption code data', 400); + const { redeem_type, value, enabled, expires_at } = body; + if (!id || !isRedeemType(redeem_type) || typeof enabled !== 'boolean' + || !validateValue(value) + ) { + return c.text('Invalid redemption code data', 400); + } + const redeemValue = parseRedeemValue(c, redeem_type, value); + if (!redeemValue) return c.text('Invalid redemption code data', 400); + const expiresAt = normalizeExpiresAt(expires_at); + if (expiresAt === undefined) return c.text('Invalid expiration time', 400); + const normalizedValue = stringifyRedeemValue(redeemValue); + const result = await c.env.DB.prepare( + `UPDATE redeem_codes + SET value = ?, enabled = ?, expires_at = ?, updated_at = datetime('now') + WHERE id = ? AND redeem_type = ? AND redeemed = 0` + ).bind(normalizedValue, enabled ? 1 : 0, expiresAt, id, redeem_type).run(); + if ((result.meta.changes ?? 0) !== 1) { + const redeemedValue = await c.env.DB.prepare( + `SELECT redeemed FROM redeem_codes WHERE id = ? AND redeem_type = ?` + ).bind(id, redeem_type).first('redeemed'); + const redeemed = redeemedValue === 1; + return redeemed + ? c.text('Redemption code already redeemed', 409) + : c.text('Redemption code not found', 404); + } + return c.json({ success: true }); +} + +const deleteRedeemCode = async (c: Context) => { + const id = parsePositiveId(c.req.param('id')); + if (!id) return c.text('Invalid redemption code id', 400); + const result = await c.env.DB.prepare( + `DELETE FROM redeem_codes WHERE id = ?` + ).bind(id).run(); + if ((result.meta.changes ?? 0) !== 1) return c.text('Redemption code not found', 404); + return c.json({ success: true }); +} + +const csvCell = (value: unknown): string => { + if (value === null || value === undefined) return ''; + let text = String(value); + if (/^[=+\-@]/.test(text)) text = `'${text}`; + return `"${text.replace(/"/g, '""')}"`; +} + +const exportRedeemCodes = async (c: Context) => { + const { redeem_type, limit: rawLimit } = c.req.query(); + const limit = /^\d+$/.test(rawLimit || '') ? Number(rawLimit) : NaN; + if (!isRedeemType(redeem_type)) { + return c.text('Invalid redemption code type', 400); + } + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_EXPORT_SIZE) { + return c.text(`Export limit must be between 1 and ${MAX_EXPORT_SIZE}`, 400); + } + const rows = await c.env.DB.prepare( + `SELECT id, code, redeem_type, value, result, enabled, redeemed, + expires_at, redeemed_at, created_at, updated_at + FROM redeem_codes + WHERE redeem_type = ? + ORDER BY id DESC LIMIT ?` + ).bind(redeem_type, limit).all<{ + id: number, + result: string | null, + [key: string]: unknown, + }>(); + const businessColumns = redeem_type === RedeemType.Role + ? ['role', 'redeemed_user_id'] + : redeem_type === RedeemType.SendBalance + ? ['amount', 'target_address'] + : ['prefix', 'result_address']; + const columns = [ + 'id', 'code', 'redeem_type', ...businessColumns, 'enabled', 'redeemed', 'expires_at', + 'redeemed_at', 'value', 'result', 'created_at', 'updated_at', + ]; + const exportedRows = await Promise.all(rows.results.map(async (row) => { + const resultValue = await decryptRedeemResult(c, row.id, row.result); + const result = getJsonObjectValue>(resultValue) || {}; + return { + ...row, + result: resultValue, + role: redeem_type === RedeemType.Role ? row.value : undefined, + redeemed_user_id: result.user_id, + amount: redeem_type === RedeemType.SendBalance ? row.value : undefined, + target_address: result.address, + prefix: redeem_type === RedeemType.AddressPrefixOnce ? row.value : undefined, + result_address: result.address, + }; + })); + const csv = [ + columns.join(','), + ...exportedRows.map((row) => columns.map((column) => csvCell(row[column])).join(',')), + ].join('\n'); + return new Response(`\uFEFF${csv}`, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="redeem-codes-${redeem_type}.csv"`, + }, + }); +} + +export default { + requireRedeemCodeEnabled, + listRedeemCodes, + createRedeemCodes, + updateRedeemCode, + deleteRedeemCode, + exportRedeemCodes, +}; diff --git a/worker/src/redeem_api/index.ts b/worker/src/redeem_api/index.ts new file mode 100644 index 0000000..076126b --- /dev/null +++ b/worker/src/redeem_api/index.ts @@ -0,0 +1,12 @@ +import { Hono } from 'hono'; + +import redeemCode from './redeem_code'; +import redeemCodeQuery from './redeem_code_query'; +import redeemResultQuery from './redeem_result_query'; + +export const api = new Hono(); + +api.use('/redeem_api/*', redeemCode.requireRedeemCodeEnabled); +api.post('/redeem_api/query', redeemCodeQuery.queryRedeemCode); +api.post('/redeem_api/result', redeemResultQuery.queryRedeemResult); +api.post('/redeem_api/redeem', redeemCode.redeemCode); diff --git a/worker/src/redeem_api/redeem_code.ts b/worker/src/redeem_api/redeem_code.ts new file mode 100644 index 0000000..2786a20 --- /dev/null +++ b/worker/src/redeem_api/redeem_code.ts @@ -0,0 +1,442 @@ +import { Context, Next } from 'hono'; + +import { generateRandomName, newAddress as createAddress } from '../common'; +import i18n from '../i18n'; +import { ensureDefaultSendBalance } from '../mails_api/send_balance'; +import { + getBooleanValue, + getIntValue, + getStringValue, + getUserRoles, + getJsonObjectValue, + trimLower, +} from '../utils'; + +export enum RedeemType { + Role = 'role', + SendBalance = 'send_balance', + AddressPrefixOnce = 'address_prefix_once', +} + +export type RedeemValue = + | { type: RedeemType.Role, role: string } + | { type: RedeemType.SendBalance, amount: number } + | { type: RedeemType.AddressPrefixOnce, prefix: string }; + +export type RedeemCodeRow = { + id: number, + code: string, + redeem_type: string, + value: string, + redeemed: 0 | 1, + result: string | null, +}; + +export type RoleRedeemResult = { + type: RedeemType.Role, + user_id: number, + user_email: string, + role: string, +}; + +export type SendBalanceRedeemResult = { + type: RedeemType.SendBalance, + address: string, + amount: number, +}; + +export type AddressRedeemResult = { + type: RedeemType.AddressPrefixOnce, + address: string, + address_id: number, + jwt: string, + password?: string | null, +}; + +export const isRedeemType = (value: unknown): value is RedeemType => ( + typeof value === 'string' && Object.values(RedeemType).some((type) => type === value) +); + +const RESULT_ENCRYPTION_PREFIX = 'enc:v1:'; +let cachedResultKey: { secret: string, key: Promise } | null = null; + +const encodeBase64Url = (value: Uint8Array): string => { + let binary = ''; + for (const byte of value) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const decodeBase64Url = (value: string): Uint8Array => { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +}; + +const getResultEncryptionKey = async (c: Context): Promise => { + if (cachedResultKey?.secret === c.env.JWT_SECRET) return await cachedResultKey.key; + const secret = c.env.JWT_SECRET; + const key = crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(`redeem-result:${secret}`), + ).then((keyBytes) => ( + crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt', 'decrypt']) + )); + cachedResultKey = { secret, key }; + return await key; +}; + +const getResultAdditionalData = (rowId: number): Uint8Array => ( + new TextEncoder().encode(`redeem-result:${rowId}`) +); + +export const encryptRedeemResult = async ( + c: Context, + rowId: number, + value: string, +): Promise => { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt({ + name: 'AES-GCM', + iv, + additionalData: getResultAdditionalData(rowId), + }, await getResultEncryptionKey(c), new TextEncoder().encode(value)); + return `${RESULT_ENCRYPTION_PREFIX}${encodeBase64Url(iv)}.${encodeBase64Url(new Uint8Array(encrypted))}`; +}; + +export const decryptRedeemResult = async ( + c: Context, + rowId: number, + value: string | null, +): Promise => { + if (!value) return value; + try { + if (!value.startsWith(RESULT_ENCRYPTION_PREFIX)) return null; + const [ivValue, encryptedValue, ...extra] = value.slice(RESULT_ENCRYPTION_PREFIX.length).split('.'); + if (!ivValue || !encryptedValue || extra.length) return null; + const decrypted = await crypto.subtle.decrypt({ + name: 'AES-GCM', + iv: decodeBase64Url(ivValue), + additionalData: getResultAdditionalData(rowId), + }, await getResultEncryptionKey(c), decodeBase64Url(encryptedValue)); + return new TextDecoder().decode(decrypted); + } catch (error) { + console.warn(`Failed to decrypt redemption result ${rowId}`, error); + return null; + } +}; + +export const parseRedeemValue = ( + c: Context, + redeemType: RedeemType, + value: string, +): RedeemValue | null => { + const normalizedValue = redeemType === RedeemType.AddressPrefixOnce ? trimLower(value) : value.trim(); + if (redeemType === RedeemType.Role && normalizedValue + && normalizedValue !== c.env.ADMIN_USER_ROLE + && getUserRoles(c).some((item) => item.role === normalizedValue) + ) { + return { type: RedeemType.Role, role: normalizedValue }; + } + if (redeemType === RedeemType.SendBalance && /^\d+$/.test(normalizedValue)) { + const amount = Number(normalizedValue); + if (amount > 0 && amount <= 1_000_000_000) { + return { type: RedeemType.SendBalance, amount }; + } + } + if (redeemType === RedeemType.AddressPrefixOnce) { + const maxAddressLength = Math.max(getIntValue(c.env.MAX_ADDRESS_LEN, 30), 1); + if (normalizedValue.length < maxAddressLength && /^[a-z0-9]*$/.test(normalizedValue)) { + return { type: RedeemType.AddressPrefixOnce, prefix: normalizedValue }; + } + } + return null; +}; + +export const stringifyRedeemValue = (value: RedeemValue): string => { + if (value.type === RedeemType.Role) return value.role; + if (value.type === RedeemType.SendBalance) return String(value.amount); + return value.prefix; +}; + +export const normalizeRedeemCode = (code: unknown): string | null => { + if (typeof code !== 'string') return null; + const normalizedCode = code.trim(); + return normalizedCode && normalizedCode.length <= 256 ? normalizedCode : null; +}; + +export const getRedeemCodeForRedemption = async ( + c: Context, + code: unknown, +): Promise<{ row: RedeemCodeRow, redeemValue: RedeemValue } | Response> => { + const msgs = i18n.getMessagesbyContext(c); + const normalizedCode = normalizeRedeemCode(code); + if (!normalizedCode) return c.text(msgs.RedeemCodeUnavailableMsg, 400); + const row = await c.env.DB.prepare( + `SELECT id, code, redeem_type, value, redeemed, result + FROM redeem_codes + WHERE code = ? AND enabled = 1 + AND datetime(expires_at) > datetime('now')` + ).bind(normalizedCode).first(); + if (!row) return c.text(msgs.RedeemCodeUnavailableMsg, 400); + if (!isRedeemType(row.redeem_type)) return c.text(msgs.RedeemCodeInvalidMsg, 400); + const redeemValue = parseRedeemValue(c, row.redeem_type, row.value); + if (!redeemValue) return c.text(msgs.RedeemCodeInvalidMsg, 400); + if (row.redeemed && redeemValue.type !== RedeemType.AddressPrefixOnce) { + return c.text(msgs.RedeemCodeUnavailableMsg, 400); + } + return { row, redeemValue }; +}; + +export const requireRedeemCodeEnabled = async ( + c: Context, + next: Next, +) => { + if (!getBooleanValue(c.env.ENABLE_REDEEM_CODE)) return c.notFound(); + await next(); +}; + +type RedeemCodeInput = { + code?: unknown, + user_email?: unknown, + address?: unknown, + name?: string, + domain?: string, + enableRandomSubdomain?: boolean | string, +}; + +const redeemRole = async ( + c: Context, + userEmail: unknown, + row: RedeemCodeRow, + redeemValue: Extract, +) => { + const msgs = i18n.getMessagesbyContext(c); + if (typeof userEmail !== 'string' || !userEmail.trim()) { + return c.text(msgs.UserNotFoundMsg, 400); + } + const user = await c.env.DB.prepare( + `SELECT id, user_email FROM users WHERE user_email = ? COLLATE NOCASE` + ).bind(userEmail.trim()).first<{ id: number, user_email: string }>(); + if (!user) return c.text(msgs.UserNotFoundMsg, 400); + const currentRole = await c.env.DB.prepare( + `SELECT role_text FROM user_roles WHERE user_id = ?` + ).bind(user.id).first('role_text'); + const defaultRole = getStringValue(c.env.USER_DEFAULT_ROLE); + if (currentRole && currentRole !== redeemValue.role && currentRole !== defaultRole) { + return c.text(msgs.RedeemRoleConflictMsg, 409); + } + const redemptionResult: RoleRedeemResult = { + type: redeemValue.type, + user_id: user.id, + user_email: user.user_email, + role: redeemValue.role, + }; + const encryptedResult = await encryptRedeemResult(c, row.id, JSON.stringify(redemptionResult)); + const results = await c.env.DB.batch([ + c.env.DB.prepare( + `UPDATE redeem_codes + SET result = ? + WHERE id = ? AND code = ? AND redeem_type = ? AND value = ? + AND enabled = 1 AND redeemed = 0 + AND datetime(expires_at) > datetime('now')` + ).bind( + encryptedResult, row.id, row.code, row.redeem_type, row.value, + ), + // changes() refers to the preceding write in this batch. + c.env.DB.prepare( + `INSERT INTO user_roles(user_id, role_text) + SELECT ?, ? WHERE changes() = 1 + ON CONFLICT(user_id) DO UPDATE SET + role_text = excluded.role_text, updated_at = datetime('now') + WHERE user_roles.role_text IS NULL OR user_roles.role_text = '' + OR user_roles.role_text = excluded.role_text OR user_roles.role_text = ?` + ).bind(user.id, redeemValue.role, defaultRole), + c.env.DB.prepare( + `UPDATE redeem_codes + SET redeemed = 1, redeemed_at = datetime('now'), updated_at = datetime('now') + WHERE id = ? AND result = ? AND redeemed = 0 AND changes() = 1` + ).bind(row.id, encryptedResult), + c.env.DB.prepare( + `UPDATE redeem_codes SET result = NULL + WHERE id = ? AND result = ? AND redeemed = 0` + ).bind(row.id, encryptedResult), + ]); + if ((results[2].meta.changes ?? 0) !== 1) { + return c.text(msgs.RedeemCodeUnavailableMsg, 409); + } + return c.json({ + success: true, + type: redeemValue.type, + role: redeemValue.role, + user_email: user.user_email, + }); +}; + +const redeemSendBalance = async ( + c: Context, + rawAddress: unknown, + row: RedeemCodeRow, + redeemValue: Extract, +) => { + const address = trimLower(rawAddress); + const msgs = i18n.getMessagesbyContext(c); + if (!address) return c.text(msgs.AddressNotFoundMsg, 400); + const addressExists = await c.env.DB.prepare( + `SELECT id FROM address WHERE name = ?` + ).bind(address).first('id'); + if (!addressExists) return c.text(msgs.AddressNotFoundMsg, 400); + await ensureDefaultSendBalance(c, address); + + const redemptionResult: SendBalanceRedeemResult = { + type: redeemValue.type, + address, + amount: redeemValue.amount, + }; + const encryptedResult = await encryptRedeemResult(c, row.id, JSON.stringify(redemptionResult)); + const results = await c.env.DB.batch([ + c.env.DB.prepare( + `UPDATE redeem_codes + SET redeemed = 1, redeemed_at = datetime('now'), result = ?, updated_at = datetime('now') + WHERE id = ? AND code = ? AND redeem_type = ? AND value = ? + AND enabled = 1 AND redeemed = 0 + AND datetime(expires_at) > datetime('now')` + ).bind(encryptedResult, row.id, row.code, row.redeem_type, row.value), + c.env.DB.prepare( + `INSERT INTO address_sender(address, balance, enabled) + SELECT ?, ?, 1 WHERE changes() = 1 + ON CONFLICT(address) DO UPDATE SET + balance = COALESCE(address_sender.balance, 0) + excluded.balance` + ).bind(address, redeemValue.amount), + ]); + if ((results[0].meta.changes ?? 0) !== 1 || (results[1].meta.changes ?? 0) !== 1) { + return c.text(msgs.RedeemCodeUnavailableMsg, 409); + } + const balance = await c.env.DB.prepare( + `SELECT balance FROM address_sender WHERE address = ?` + ).bind(address).first('balance'); + return c.json({ + success: true, + type: redeemValue.type, + address, + amount: redeemValue.amount, + balance, + }); +}; + +const claimRedeemedAddress = async ( + c: Context, + row: RedeemCodeRow, + result: AddressRedeemResult, +) => { + const encryptedResult = await encryptRedeemResult(c, row.id, JSON.stringify(result)); + return await c.env.DB.prepare( + `UPDATE redeem_codes + SET redeemed = 1, redeemed_at = datetime('now'), result = ?, updated_at = datetime('now') + WHERE id = ? AND code = ? AND redeem_type = ? AND value = ? + AND enabled = 1 AND redeemed = 0 + AND datetime(expires_at) > datetime('now')` + ).bind(encryptedResult, row.id, row.code, row.redeem_type, row.value).run(); +}; + +export const getRedeemedAddress = async ( + c: Context, + row: Pick, +): Promise => { + const result = await decryptRedeemResult(c, row.id, row.result); + const context = getJsonObjectValue(result); + if (context?.type !== RedeemType.AddressPrefixOnce + || typeof context.address !== 'string' + || typeof context.address_id !== 'number' + || typeof context.jwt !== 'string' + || (context.password !== undefined + && context.password !== null + && typeof context.password !== 'string') + ) return null; + const addressId = await c.env.DB.prepare( + `SELECT id FROM address WHERE id = ? AND name = ?` + ).bind(context.address_id, context.address).first('id'); + if (!addressId) return null; + return context; +}; + +const redeemAddress = async ( + c: Context, + body: RedeemCodeInput, + row: RedeemCodeRow, + redeemValue: Extract, +) => { + const msgs = i18n.getMessagesbyContext(c); + if (row.redeemed) { + const result = await getRedeemedAddress(c, row); + return result ? c.json(result) : c.text(msgs.RedeemCodeUnavailableMsg, 400); + } + const maxNameLength = Math.max( + getIntValue(c.env.MAX_ADDRESS_LEN, 30) - redeemValue.prefix.length, + 1, + ); + const name = !body.name || getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME) + ? generateRandomName(c).slice(0, maxNameLength) + : body.name; + const sourceMeta = `redeem:${row.id}`; + let created: Awaited>; + try { + created = await createAddress(c, { + name: `${redeemValue.prefix}${name}`, + domain: body.domain, + enablePrefix: false, + enableRandomSubdomain: getBooleanValue(body.enableRandomSubdomain), + checkLengthByConfig: true, + addressPrefix: null, + sourceMeta, + }); + } catch (error) { + const latest = await getRedeemCodeForRedemption(c, body.code); + if (!(latest instanceof Response)) { + const concurrentAddress = await getRedeemedAddress(c, latest.row); + if (concurrentAddress) return c.json(concurrentAddress); + } + return c.text(`${msgs.FailedCreateAddressMsg}: ${(error as Error).message}`, 400); + } + + try { + const redemptionResult: AddressRedeemResult = { type: redeemValue.type, ...created }; + const claimed = await claimRedeemedAddress(c, row, redemptionResult); + if ((claimed.meta.changes ?? 0) === 1) return c.json(redemptionResult); + } catch (error) { + await c.env.DB.prepare( + `DELETE FROM address WHERE id = ? AND name = ?` + ).bind(created.address_id, created.address).run(); + throw error; + } + + await c.env.DB.prepare( + `DELETE FROM address WHERE id = ? AND name = ?` + ).bind(created.address_id, created.address).run(); + const latest = await getRedeemCodeForRedemption(c, body.code); + if (latest instanceof Response) return latest; + const concurrentAddress = await getRedeemedAddress(c, latest.row); + return concurrentAddress + ? c.json(concurrentAddress) + : c.text(msgs.RedeemCodeUnavailableMsg, 409); +}; + +const redeemCode = async (c: Context) => { + const body = await c.req.json().catch(() => null); + if (!body) return c.text(i18n.getMessagesbyContext(c).RedeemCodeInvalidMsg, 400); + const result = await getRedeemCodeForRedemption(c, body.code); + if (result instanceof Response) return result; + + const { row, redeemValue } = result; + if (redeemValue.type === RedeemType.Role) { + return await redeemRole(c, body.user_email, row, redeemValue); + } + if (redeemValue.type === RedeemType.SendBalance) { + return await redeemSendBalance(c, body.address, row, redeemValue); + } + return await redeemAddress(c, body, row, redeemValue); +}; + +export default { + requireRedeemCodeEnabled, + redeemCode, +}; diff --git a/worker/src/redeem_api/redeem_code_query.ts b/worker/src/redeem_api/redeem_code_query.ts new file mode 100644 index 0000000..3e0094c --- /dev/null +++ b/worker/src/redeem_api/redeem_code_query.ts @@ -0,0 +1,38 @@ +import { Context } from 'hono'; + +import i18n from '../i18n'; +import { isRedeemType, normalizeRedeemCode } from './redeem_code'; + +type QueryRedeemCodeRow = { + redeem_type: string, + value: string, + redeemed: 0 | 1, + expires_at: string, +}; + +const queryRedeemCode = async (c: Context) => { + const body = await c.req.json<{ code?: unknown }>().catch(() => null); + const normalizedCode = normalizeRedeemCode(body?.code); + const msgs = i18n.getMessagesbyContext(c); + if (!normalizedCode) return c.text(msgs.RedeemCodeUnavailableMsg, 400); + + const row = await c.env.DB.prepare( + `SELECT redeem_type, value, redeemed, expires_at + FROM redeem_codes + WHERE code = ? AND enabled = 1` + ).bind(normalizedCode).first(); + if (!row || !isRedeemType(row.redeem_type)) { + return c.text(msgs.RedeemCodeUnavailableMsg, 400); + } + const expiresAt = Date.parse(row.expires_at); + const status = !Number.isFinite(expiresAt) || expiresAt <= Date.now() + ? 'expired' + : row.redeemed === 1 ? 'redeemed' : 'unused'; + return c.json({ + redeem_type: row.redeem_type, + value: row.value, + status, + }); +}; + +export default { queryRedeemCode }; diff --git a/worker/src/redeem_api/redeem_result_query.ts b/worker/src/redeem_api/redeem_result_query.ts new file mode 100644 index 0000000..d123695 --- /dev/null +++ b/worker/src/redeem_api/redeem_result_query.ts @@ -0,0 +1,71 @@ +import { Context } from 'hono'; + +import i18n from '../i18n'; +import { getJsonObjectValue } from '../utils'; +import { + decryptRedeemResult, + getRedeemedAddress, + normalizeRedeemCode, + RedeemType, + RoleRedeemResult, + SendBalanceRedeemResult, +} from './redeem_code'; + +type RedeemResultRow = { + id: number, + redeem_type: string, + result: string, +}; + +type RedeemResult = Record & { + type: string, +}; + +const getResultResponse = (result: RedeemResult): Record | null => { + if (result.type === RedeemType.Role) { + const roleResult = result as RoleRedeemResult; + if (typeof roleResult.user_email !== 'string' || typeof roleResult.role !== 'string') return null; + return { type: roleResult.type, user_email: roleResult.user_email, role: roleResult.role }; + } + if (result.type === RedeemType.SendBalance) { + const balanceResult = result as SendBalanceRedeemResult; + if (typeof balanceResult.address !== 'string' || typeof balanceResult.amount !== 'number') return null; + return { type: balanceResult.type, address: balanceResult.address, amount: balanceResult.amount }; + } + return null; +}; + +const queryRedeemResult = async (c: Context) => { + const body = await c.req.json<{ code?: unknown }>().catch(() => null); + const normalizedCode = normalizeRedeemCode(body?.code); + const msgs = i18n.getMessagesbyContext(c); + if (!normalizedCode) return c.text(msgs.RedeemCodeUnavailableMsg, 400); + + const row = await c.env.DB.prepare( + `SELECT id, redeem_type, result + FROM redeem_codes + WHERE code = ? AND enabled = 1 AND redeemed = 1 AND result IS NOT NULL + AND datetime(expires_at) > datetime('now')` + ).bind(normalizedCode).first(); + if (!row) return c.text(msgs.RedeemCodeUnavailableMsg, 400); + + if (row.redeem_type === RedeemType.AddressPrefixOnce) { + const result = await getRedeemedAddress(c, row); + if (!result) return c.text(msgs.RedeemCodeUnavailableMsg, 400); + return c.json({ + type: result.type, + address: result.address, + jwt: result.jwt, + ...(typeof result.password === 'string' ? { password: result.password } : {}), + }); + } + const decrypted = await decryptRedeemResult(c, row.id, row.result); + const result = getJsonObjectValue(decrypted); + if (!result || result.type !== row.redeem_type) { + return c.text(msgs.RedeemCodeUnavailableMsg, 400); + } + const response = getResultResponse(result); + return response ? c.json(response) : c.text(msgs.RedeemCodeUnavailableMsg, 400); +}; + +export default { queryRedeemResult }; diff --git a/worker/src/types.d.ts b/worker/src/types.d.ts index 8dc64f9..b11c520 100644 --- a/worker/src/types.d.ts +++ b/worker/src/types.d.ts @@ -60,6 +60,8 @@ type Bindings = { ENABLE_USER_DELETE_EMAIL: string | boolean | undefined ENABLE_ADDRESS_PASSWORD: string | boolean | undefined ENABLE_AGENT_EMAIL_INFO: string | boolean | undefined + ENABLE_REDEEM_CODE: string | boolean | undefined + REDEEM_CODE_URL: string | undefined SMTP_IMAP_PROXY_CONFIG: string | SmtpImapProxyConfig | undefined ENABLE_INDEX_ABOUT: string | boolean | undefined DEFAULT_SEND_BALANCE: number | string | undefined diff --git a/worker/src/worker.ts b/worker/src/worker.ts index 8326fa2..a106db9 100644 --- a/worker/src/worker.ts +++ b/worker/src/worker.ts @@ -10,6 +10,7 @@ import { api as userApi } from './user_api'; import { api as adminApi } from './admin_api'; import { api as apiSendMail } from './mails_api/send_mail_api' import { api as telegramApi } from './telegram_api' +import { api as redeemApi } from './redeem_api' import i18n from './i18n'; import { email } from './email'; @@ -24,6 +25,7 @@ const API_PATHS = [ "/admin/", "/telegram/", "/external/", + "/redeem_api/", ]; const app = new Hono() @@ -53,7 +55,10 @@ app.use('/*', async (c, next) => { // check header x-custom-auth const passwords = getPasswords(c); - if (!c.req.path.startsWith("/open_api") && !c.req.path.startsWith("/telegram/") && passwords && passwords.length > 0) { + if (!c.req.path.startsWith("/open_api") + && !c.req.path.startsWith("/telegram/") + && passwords && passwords.length > 0 + ) { const auth = c.req.raw.headers.get("x-custom-auth"); if (!auth || !passwords.includes(auth)) { return c.text(msgs.CustomAuthPasswordMsg, 401) @@ -68,6 +73,7 @@ app.use('/*', async (c, next) => { || (c.req.path.startsWith("/user_api/address/") && c.req.path.endsWith("/send_mail")) || c.req.path.startsWith("/user_api/register") || c.req.path.startsWith("/user_api/verify_code") + || c.req.path.startsWith("/redeem_api/") ) { const reqIp = c.req.raw.headers.get("cf-connecting-ip") if (reqIp && c.env.RATE_LIMITER) { @@ -281,6 +287,7 @@ app.route('/', userApi) app.route('/', adminApi) app.route('/', apiSendMail) app.route('/', telegramApi) +app.route('/', redeemApi) const health_check = async (c: Context) => { const lang = c.req.raw.headers.get("x-lang") || c.env.DEFAULT_LANG; diff --git a/worker/wrangler.toml.template b/worker/wrangler.toml.template index f77b89f..183e671 100644 --- a/worker/wrangler.toml.template +++ b/worker/wrangler.toml.template @@ -79,6 +79,10 @@ ENABLE_USER_CREATE_EMAIL = true # DISABLE_ANONYMOUS_USER_CREATE_EMAIL = true # Allow users to delete messages ENABLE_USER_DELETE_EMAIL = true +# Enable the redemption code page and APIs +# ENABLE_REDEEM_CODE = true +# Optional external link for obtaining redemption codes +# REDEEM_CODE_URL = "https://example.com/redeem-codes" # Track read and unread mail. Run the database migration before enabling. # ENABLE_MAIL_READ_STATUS = true # Allow automatic replies to emails