Compare commits

...
6 Commits
Author SHA1 Message Date
Dream Hunter b5c229b6c4 feat: v1.0.6 (#743) 2025-10-12 18:24:36 +08:00
Dream Hunter 2728e9667b fix: 针对角色配置不同的绑定地址数量上限 (#742) 2025-10-12 18:21:22 +08:00
Dream HunterandClaude 6109ab9e82 feat: add role-based address limit configuration (#741)
- Add RoleAddressConfig component in admin panel
- Implement role_address_config API endpoints (GET/POST)
- Add getMaxAddressCount function with validation chain
- Priority: role config > global settings
- Support editable table with clearable input
- Add extensible RoleConfig type for future fields
- Use context for current user, query DB for target user
- Optimize state management (remove redundant roleConfigsMap)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 18:12:36 +08:00
Dream Hunter 09a6cac8fe feat: upgrade dependencies (#740) 2025-10-12 13:47:40 +08:00
Dream HunterandClaude 5f752c94f9 feat: add cleanup for unbound addresses feature (#739)
- Add unboundAddress cleanup type to backend cleanup logic
- Update CleanupSettings model with unbound address fields
- Add scheduled task for automatic unbound address cleanup
- Add UI controls in admin Maintenance panel for manual cleanup
- Add i18n support (en/zh) for unbound address cleanup labels
- Clean addresses not bound to any user created before n days

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 13:46:13 +08:00
Dream HunterandClaude a2f3634c7e feat: add multi-select batch operations for admin account management (#737)
- Add multi-select functionality with native Naive UI selection column
- Implement batch operations: delete, clear inbox, clear sent items
- Create reusable executeBatchOperation function to eliminate code duplication
- Add error recovery mechanism: failed items remain selected for retry
- Add progress modal with real-time percentage display
- Support smart skip logic: skip addresses with no mails/sent items
- Add i18n support for English and Chinese
- Update CHANGELOG.md with new features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-04 20:04:26 +08:00
20 changed files with 1578 additions and 1175 deletions
+6 -2
View File
@@ -1,10 +1,14 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
## main(v1.0.6)
## v1.0.6
- feat: |DB| update db schema add index
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用, 需要执行 `db/2025-09-23-patch.sql` 文件中的 SQL 更新 `D1` 数据库
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
- feat: |Admin| 维护页面增加清理未绑定用户地址的功能
- feat: 支持针对角色配置不同的绑定地址数量上限, 可在 admin 页面配置
## v1.0.5
+2 -2
View File
@@ -29,7 +29,7 @@
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.43.1",
"postal-mime": "^2.4.4",
"postal-mime": "^2.5.0",
"vooks": "^0.2.12",
"vue": "^3.5.22",
"vue-clipboard3": "^2.0.0",
@@ -48,7 +48,7 @@
"vite-plugin-wasm": "^3.5.0",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0",
"wrangler": "^4.40.1"
"wrangler": "^4.42.2"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+270 -263
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -14,6 +14,7 @@ import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
import RoleAddressConfig from './admin/RoleAddressConfig.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
@@ -61,6 +62,7 @@ const { t } = useI18n({
user_management: 'User Management',
user_settings: 'User Settings',
userOauth2Settings: 'Oauth2 Settings',
roleAddressConfig: 'Role Address Config',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
@@ -88,6 +90,7 @@ const { t } = useI18n({
user_management: '用户管理',
user_settings: '用户设置',
userOauth2Settings: 'Oauth2 设置',
roleAddressConfig: '角色地址配置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
@@ -173,6 +176,9 @@ onMounted(async () => {
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
<UserOauth2Settings />
</n-tab-pane>
<n-tab-pane name="roleAddressConfig" :tab="t('roleAddressConfig')">
<RoleAddressConfig />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
+172 -5
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { NBadge } from 'naive-ui'
import { ref, h, onMounted, watch, computed } from 'vue';
import { NBadge, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
@@ -42,6 +42,16 @@ const { t } = useI18n({
resetPassword: 'Reset Password',
newPassword: 'New Password',
passwordResetSuccess: 'Password reset successfully',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
pleaseSelectAddress: 'Please select address',
selectedItems: 'Selected',
multiDelete: 'Multi Delete',
multiDeleteTip: 'Are you sure to delete selected addresses?',
multiClearInbox: 'Multi Clear Inbox',
multiClearInboxTip: 'Are you sure to clear inbox for selected addresses?',
multiClearSentItems: 'Multi Clear Sent Items',
multiClearSentItemsTip: 'Are you sure to clear sent items for selected addresses?',
},
zh: {
name: '名称',
@@ -69,6 +79,16 @@ const { t } = useI18n({
resetPassword: '重置密码',
newPassword: '新密码',
passwordResetSuccess: '密码重置成功',
selectAll: '全选本页',
unselectAll: '取消全选',
pleaseSelectAddress: '请选择地址',
selectedItems: '已选择',
multiDelete: '批量删除',
multiDeleteTip: '确定要删除选中的邮箱吗?',
multiClearInbox: '批量清空收件箱',
multiClearInboxTip: '确定要清空选中邮箱的收件箱吗?',
multiClearSentItems: '批量清空发件箱',
multiClearSentItemsTip: '确定要清空选中邮箱的发件箱吗?',
}
}
});
@@ -82,6 +102,15 @@ const showResetPassword = ref(false);
const curResetPasswordAddressId = ref(0);
const newPassword = ref('');
// Multi-action mode state
const checkedRowKeys = ref([]);
const showMultiActionModal = ref(false);
const multiActionProgress = ref({ percentage: 0, tip: '0/0' });
const multiActionTitle = ref('');
const selectedCount = computed(() => checkedRowKeys.value.length);
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
const addressQuery = ref("")
const data = ref([])
@@ -159,6 +188,98 @@ const resetPassword = async () => {
}
}
// Multi-action mode functions
const multiActionSelectAll = () => {
checkedRowKeys.value = data.value.map(item => item.id);
}
const multiActionUnselectAll = () => {
checkedRowKeys.value = [];
}
//
const executeBatchOperation = async ({
shouldSkip = () => false,
apiCall,
title,
operationName = 'operation'
}) => {
try {
loading.value = true;
const selectedAddresses = data.value.filter((item) =>
checkedRowKeys.value.includes(item.id)
);
if (selectedAddresses.length === 0) {
message.error(t('pleaseSelectAddress'));
return;
}
const failedIds = [];
const totalCount = selectedAddresses.length;
multiActionProgress.value = {
percentage: 0,
tip: `0/${totalCount}`
};
multiActionTitle.value = title;
showMultiActionModal.value = true;
for (const [index, address] of selectedAddresses.entries()) {
try {
if (!shouldSkip(address)) {
await apiCall(address.id);
}
} catch (error) {
console.error(`${operationName} failed for address ${address.id}:`, error);
failedIds.push(address.id);
}
multiActionProgress.value = {
percentage: Math.floor((index + 1) / totalCount * 100),
tip: `${index + 1}/${totalCount}`
};
}
await fetchData();
checkedRowKeys.value = failedIds;
message.success(t("success"));
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
}
}
const multiActionDeleteAccounts = async () => {
await executeBatchOperation({
apiCall: (id) => api.adminDeleteAddress(id),
title: t('multiDelete') + ' ' + t('success'),
operationName: 'Delete'
});
}
const multiActionClearInbox = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.mail_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_inbox/${id}`, {
method: 'DELETE'
}),
title: t('multiClearInbox') + ' ' + t('success'),
operationName: 'ClearInbox'
});
}
const multiActionClearSentItems = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.send_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_sent_items/${id}`, {
method: 'DELETE'
}),
title: t('multiClearSentItems') + ' ' + t('success'),
operationName: 'ClearSentItems'
});
}
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim()
@@ -173,12 +294,15 @@ const fetchData = async () => {
count.value = addressCount;
}
} catch (error) {
console.log(error)
console.error(error);
message.error(error.message || "error");
}
}
const columns = [
{
type: 'selection'
},
{
title: "ID",
key: "id"
@@ -414,13 +538,43 @@ onMounted(async () => {
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input-group style="margin-bottom: 10px;">
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
@keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<n-space v-if="showMultiActionBar" style="margin-bottom: 10px;">
<n-button @click="multiActionSelectAll" tertiary>
{{ t('selectAll') }}
</n-button>
<n-button @click="multiActionUnselectAll" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm @positive-click="multiActionDeleteAccounts">
<template #trigger>
<n-button tertiary type="error">{{ t('multiDelete') }}</n-button>
</template>
{{ t('multiDeleteTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearInbox">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearInbox') }}</n-button>
</template>
{{ t('multiClearInboxTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearSentItems">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearSentItems') }}</n-button>
</template>
{{ t('multiClearSentItemsTip') }}
</n-popconfirm>
<n-tag type="info">
{{ t('selectedItems') }}: {{ selectedCount }}
</n-tag>
</n-space>
<div style="overflow: auto;">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
@@ -430,8 +584,21 @@ onMounted(async () => {
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
:row-key="row => row.id" embedded />
</div>
<!-- Multi-action progress modal -->
<n-modal v-model:show="showMultiActionModal" preset="dialog" :title="multiActionTitle" negative-text="OK">
<n-space justify="center">
<n-progress type="circle" status="info" :percentage="multiActionProgress.percentage">
<span style="text-align: center">
{{ multiActionProgress.tip }}
</span>
</n-progress>
</n-space>
</n-modal>
</div>
</template>
+16
View File
@@ -17,6 +17,8 @@ const cleanupModel = ref({
cleanAddressDays: 30,
enableInactiveAddressAutoCleanup: false,
cleanInactiveAddressDays: 30,
enableUnboundAddressAutoCleanup: false,
cleanUnboundAddressDays: 30,
})
const { t } = useI18n({
@@ -28,6 +30,7 @@ const { t } = useI18n({
sendBoxLabel: "Cleanup the sendbox before n days",
addressCreateLabel: "Cleanup the address created before n days",
inactiveAddressLabel: "Cleanup the inactive address before n days",
unboundAddressLabel: "Cleanup the unbound address before n days",
cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup",
cleanupSuccess: "Cleanup success",
@@ -41,6 +44,7 @@ const { t } = useI18n({
sendBoxLabel: "清理 n 天前的发件箱",
addressCreateLabel: "清理 n 天前创建的地址",
inactiveAddressLabel: "清理 n 天前的未活跃地址",
unboundAddressLabel: "清理 n 天前的未绑定用户地址",
autoCleanup: "自动清理",
cleanupSuccess: "清理成功",
cleanupNow: "立即清理",
@@ -161,6 +165,18 @@ onMounted(async () => {
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('unboundAddressLabel')">
<n-checkbox v-model:checked="cleanupModel.enableUnboundAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanUnboundAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('unboundAddress', cleanupModel.cleanUnboundAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
</n-form>
</n-card>
</div>
@@ -0,0 +1,153 @@
<script setup>
import { ref, onMounted, h } from 'vue';
import { useI18n } from 'vue-i18n'
import { NInputNumber, NTag, NSpace, NButton } from 'naive-ui';
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
role: 'Role',
maxAddressCount: 'Max Address Count',
save: 'Save',
successTip: 'Success',
noRolesAvailable: 'No roles available in system config',
roleConfigDesc: 'Configure maximum address count for each user role. Role-based limits take priority over global settings.',
notConfigured: 'Not Configured (Use Global Settings)',
},
zh: {
role: '角色',
maxAddressCount: '最大地址数量',
save: '保存',
successTip: '成功',
noRolesAvailable: '系统配置中没有可用的角色',
roleConfigDesc: '为每个用户角色配置最大地址数量。角色配置优先于全局设置。',
notConfigured: '未配置(使用全局设置)',
}
}
});
const systemRoles = ref([])
const tableData = ref([])
const fetchUserRoles = async () => {
try {
const results = await api.fetch(`/admin/user_roles`);
systemRoles.value = results;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const fetchRoleConfigs = async () => {
try {
const { configs } = await api.fetch(`/admin/role_address_config`);
tableData.value = systemRoles.value.map(roleObj => ({
role: roleObj.role,
max_address_count: configs[roleObj.role]?.maxAddressCount ?? null,
}));
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const saveConfig = async () => {
try {
// convert tableData to object with nested structure
const configs = {};
tableData.value.forEach(row => {
if (row.max_address_count !== null && row.max_address_count !== undefined) {
configs[row.role] = { maxAddressCount: row.max_address_count };
}
});
await api.fetch(`/admin/role_address_config`, {
method: 'POST',
body: JSON.stringify({ configs })
});
message.success(t('successTip'));
await fetchRoleConfigs();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: t('role'),
key: 'role',
width: 200,
render(row) {
return h(NTag, {
type: 'info',
bordered: false
}, {
default: () => row.role
})
}
},
{
title: t('maxAddressCount'),
key: 'max_address_count',
render(row) {
return h(NInputNumber, {
value: row.max_address_count,
min: 0,
max: 999,
clearable: true,
placeholder: t('notConfigured'),
style: 'width: 200px;',
onUpdateValue: (value) => {
row.max_address_count = value;
}
})
}
}
]
onMounted(async () => {
await fetchUserRoles();
await fetchRoleConfigs();
})
</script>
<template>
<div style="margin-top: 10px;">
<n-alert type="info" :bordered="false" style="margin-bottom: 20px;">
{{ t('roleConfigDesc') }}
</n-alert>
<n-alert v-if="systemRoles.length === 0" type="warning" :bordered="false">
{{ t('noRolesAvailable') }}
</n-alert>
<div v-else>
<n-space justify="end" style="margin-bottom: 12px;">
<n-button :loading="loading" @click="saveConfig" type="primary">
{{ t('save') }}
</n-button>
</n-space>
<n-data-table
:columns="columns"
:data="tableData"
:bordered="false"
embedded
/>
</div>
</div>
</template>
<style scoped>
.n-data-table {
min-width: 600px;
}
</style>
+1 -1
View File
@@ -11,7 +11,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^4.40.1"
"wrangler": "^4.42.2"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+2 -2
View File
@@ -4,9 +4,9 @@
"version": "1.0.6",
"type": "module",
"devDependencies": {
"@types/node": "^24.5.2",
"@types/node": "^24.7.2",
"vitepress": "^1.6.4",
"wrangler": "^4.40.1"
"wrangler": "^4.42.2"
},
"scripts": {
"dev": "vitepress dev docs",
+283 -283
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -11,23 +11,23 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250926.0",
"@cloudflare/workers-types": "^4.20251011.0",
"@eslint/js": "9.18.0",
"@simplewebauthn/types": "10.0.0",
"@types/node": "^22.18.6",
"@types/node": "^22.18.10",
"eslint": "9.18.0",
"globals": "^15.15.0",
"typescript-eslint": "^8.44.1",
"wrangler": "^4.40.1"
"typescript-eslint": "^8.46.0",
"wrangler": "^4.42.2"
},
"dependencies": {
"@aws-sdk/client-s3": "3.888.0",
"@aws-sdk/s3-request-presigner": "3.888.0",
"@simplewebauthn/server": "10.0.1",
"hono": "^4.9.8",
"hono": "^4.9.11",
"jsonpath-plus": "^10.3.0",
"mimetext": "^3.0.27",
"postal-mime": "^2.4.4",
"postal-mime": "^2.5.0",
"resend": "^4.8.0",
"telegraf": "4.16.3",
"worker-mailer": "^1.1.5"
+593 -603
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains, getUserRoles } from '../utils';
import { UserSettings, GeoData, UserInfo } from "../models";
import { UserSettings, GeoData, UserInfo, RoleAddressConfig } from "../models";
import { handleListQuery } from '../common'
import UserBindAddressModule from '../user_api/bind_address';
import i18n from '../i18n';
@@ -166,4 +166,14 @@ export default {
results: results,
});
},
getRoleAddressConfig: async (c: Context<HonoCustomType>) => {
const value = await getJsonSetting<RoleAddressConfig>(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY);
const configs = value || {};
return c.json({ configs });
},
saveRoleAddressConfig: async (c: Context<HonoCustomType>) => {
const { configs } = await c.req.json<{ configs: RoleAddressConfig }>();
await saveSetting(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY, JSON.stringify(configs));
return c.json({ success: true });
},
}
+2
View File
@@ -344,6 +344,8 @@ api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
api.get('/admin/role_address_config', admin_user_api.getRoleAddressConfig)
api.post('/admin/role_address_config', admin_user_api.saveRoleAddressConfig)
api.get('/admin/users/bind_address/:user_id', admin_user_api.getBindedAddresses)
api.post('/admin/users/bind_address', admin_user_api.bindAddress)
+6
View File
@@ -251,6 +251,12 @@ export const cleanup = async (
`created_at < datetime('now', '-${cleanDays} day')`
)
break;
case "unboundAddress":
await batchDeleteAddressWithData(
c,
`id NOT IN (SELECT address_id FROM users_address) AND created_at < datetime('now', '-${cleanDays} day')`
)
break;
case "mails":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
+1
View File
@@ -14,6 +14,7 @@ export const CONSTANTS = {
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list',
EMAIL_RULE_SETTINGS_KEY: 'email_rule_settings',
ROLE_ADDRESS_CONFIG_KEY: 'role_address_config',
// KV
TG_KV_PREFIX: "temp-mail-telegram",
+9
View File
@@ -46,6 +46,8 @@ export type CleanupSettings = {
cleanAddressDays: number;
enableInactiveAddressAutoCleanup: boolean | undefined;
cleanInactiveAddressDays: number;
enableUnboundAddressAutoCleanup: boolean | undefined;
cleanUnboundAddressDays: number;
}
export class GeoData {
@@ -152,3 +154,10 @@ export type EmailRuleSettings = {
blockReceiveUnknowAddressEmail: boolean;
emailForwardingList: SubdomainForwardAddressList[]
}
export type RoleConfig = {
maxAddressCount?: number;
// future configs can be added here
}
export type RoleAddressConfig = Record<string, RoleConfig>;
+7
View File
@@ -50,4 +50,11 @@ export async function scheduled(event: ScheduledEvent, env: Bindings, ctx: any)
autoCleanupSetting.cleanAddressDays
);
}
if (autoCleanupSetting.enableUnboundAddressAutoCleanup) {
await cleanup(
{ env: env, } as Context<HonoCustomType>,
"unboundAddress",
autoCleanupSetting.cleanUnboundAddressDays
);
}
}
+29 -7
View File
@@ -1,12 +1,26 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { UserSettings } from "../models";
import { UserSettings, RoleAddressConfig } from "../models";
import { getJsonSetting } from "../utils"
import { CONSTANTS } from "../constants";
import { unbindTelegramByAddress } from '../telegram_api/common';
import i18n from '../i18n';
import { updateAddressUpdatedAt } from '../common';
import { updateAddressUpdatedAt, commonGetUserRole } from '../common';
const getMaxAddressCount = async (
c: Context<HonoCustomType>,
userRole: string | null | undefined,
settings: UserSettings
): Promise<number> => {
if (!userRole) return settings.maxAddressCount;
const roleConfigs = await getJsonSetting<RoleAddressConfig>(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY);
if (!roleConfigs) return settings.maxAddressCount;
const roleMaxCount = roleConfigs[userRole]?.maxAddressCount;
if (typeof roleMaxCount !== 'number') return settings.maxAddressCount;
if (roleMaxCount <= 0) return settings.maxAddressCount;
return roleMaxCount;
};
const UserBindAddressModule = {
bind: async (c: Context<HonoCustomType>) => {
@@ -43,11 +57,15 @@ const UserBindAddressModule = {
// check if binded address count
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
if (settings.maxAddressCount > 0) {
// get user role
const userRole = c.get("userRolePayload");
// check role-based max address count first, fallback to global settings
const maxAddressCount = await getMaxAddressCount(c, userRole, settings);
if (maxAddressCount > 0) {
const { count } = await c.env.DB.prepare(
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
).bind(user_id).first<{ count: number }>() || { count: 0 };
if (count >= settings.maxAddressCount) {
if (count >= maxAddressCount) {
return c.text("Max address count reached", 400)
}
}
@@ -194,18 +212,22 @@ const UserBindAddressModule = {
// check if target user exists
const target_user_id = await c.env.DB.prepare(
`SELECT id FROM users where user_email = ?`
).bind(target_user_email).first("id");
).bind(target_user_email).first<number>("id");
if (!target_user_id) {
return c.text("Target user not found", 400)
}
// check target user binded address count
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
if (settings.maxAddressCount > 0) {
// get target user role
const userRoleObj = await commonGetUserRole(c, target_user_id);
// check role-based max address count first, fallback to global settings
const maxAddressCount = await getMaxAddressCount(c, userRoleObj?.role, settings);
if (maxAddressCount > 0) {
const { count } = await c.env.DB.prepare(
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
).bind(target_user_id).first<{ count: number }>() || { count: 0 };
if (count >= settings.maxAddressCount) {
if (count >= maxAddressCount) {
return c.text("Target User Max address count reached", 400)
}
}
+3
View File
@@ -194,6 +194,9 @@ app.use('/user_api/*', async (c, next) => {
console.error(e);
return c.text(msgs.UserTokenExpiredMsg, 401)
}
if (c.req.path.startsWith("/user_api/bind_address")) {
await checkoutUserRolePayload(c);
}
if (c.req.path.startsWith('/user_api/bind_address')
&& c.req.method === 'POST'
) {