Compare commits

...

20 Commits

Author SHA1 Message Date
Dream Hunter
c6d0307eac Release v0.7.1 2024-08-11 22:46:40 +08:00
Dream Hunter
ac31042e69 feat: add EMAIL_KV_BLACK_LIST (#394) 2024-08-11 20:34:10 +08:00
Dream Hunter
c733d3bf4d fix: get user role before all requests (#393) 2024-08-11 19:29:49 +08:00
Dream Hunter
bf1243f4c4 release: v0.7.0 (#387) 2024-08-11 00:21:15 +08:00
Dream Hunter
15063b2e97 feat: add DISABLE_ADMIN_PASSWORD_CHECK (#386) 2024-08-11 00:10:16 +08:00
Dream Hunter
fc07f1cd87 feat: add passkey (#384) 2024-08-10 23:56:05 +08:00
Dream Hunter
9246550cc5 feat: add NO_LIMIT_SEND_ROLE (#373) 2024-08-04 21:02:11 +08:00
Dream Hunter
979b6eae1a feat: add SHOW_GITHUB config (#372) 2024-08-04 14:36:24 +08:00
Dream Hunter
10da337a9c feat: add SHOW_GITHUB config (#371) 2024-08-04 14:34:35 +08:00
Dream Hunter
9c5e8857af feat: add loading when process mails (#367) 2024-07-27 23:14:18 +08:00
Dream Hunter
84b4baa99e feat: add .github/workflows/pr_agent.yml (#366) 2024-07-27 23:06:54 +08:00
Dream Hunter
b57d46244a feat: add loading when process mails (#364) 2024-07-27 22:30:38 +08:00
Dream Hunter
5faae8796d feat: add ADMIN_USER_ROLE for user access admin panel (#363) 2024-07-27 22:04:18 +08:00
666-eth
a0805bc0ce Docs: Update new-address-api.md (#360) 2024-07-23 13:47:37 +08:00
Dream Hunter
d0ccc3ded1 v0.6.1 2024-07-22 13:09:42 +08:00
Dream Hunter
163d9451f7 feat: worker: newAddress if domain is not set, use the first domain (#358) 2024-07-22 13:05:50 +08:00
Dream Hunter
60dda7e3fe feat: add ANNOUNCEMENT (#357) 2024-07-22 13:01:38 +08:00
Dream Hunter
384eb9b041 fix: imap proxy do not support password && cleanup days translate (#356) 2024-07-19 22:40:53 +08:00
tqjason
38816cbf0f Add new workflow action and Fix cleanup bug (#355)
* Create frontend_pagefunction_deploy.yaml

* Update frontend_pagefunction_deploy.yaml

* Update cleanup_api.ts

* Update common.ts

* Update cleanup_api.ts

* Update common.ts
2024-07-19 22:34:01 +08:00
Dream Hunter
d7d1ba6b64 feat: wrangler d1 execute dev add --remote (#352) 2024-07-15 12:04:14 +08:00
50 changed files with 1045 additions and 110 deletions

View File

@@ -0,0 +1,39 @@
name: Deploy Frontend with page function
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
version: 8
run_install: false
- name: Deploy Frontend for ${{ github.ref_name }}
run: |
cd frontend/
pnpm install --no-frozen-lockfile
pnpm build:pages
cd ../pages/
echo '${{ secrets.PAGE_TOML }}' > wrangler.toml
pnpm install --no-frozen-lockfile
pnpm run deploy
echo "Deploying prodcution for ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

25
.github/workflows/pr_agent.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Codium PR Agent
on:
pull_request:
types: [opened, reopened, ready_for_review]
jobs:
pr_agent_job:
if: ${{ github.event.sender.type != 'Bot' }}
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
name: Run pr agent on every pull request, respond to user comments
steps:
- name: PR Agent action step
id: pragent
uses: Codium-ai/pr-agent@main
env:
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
CONFIG.MODEL: "gpt-4o"
CONFIG.MODEL_TURBO: "gpt-4o"
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,6 +1,33 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
## v0.7.1
- fix: 修复用户角色加载失败的问题
- feat: admin 账号设置增加来源邮件地址黑名单配置
## v0.7.0
### Breaking Changes
DB changes: 增加用户 `passkey` 表, 需要执行 `db/2024-08-10-patch.sql` 更新 `D1` 数据库
### Changes
- Docs: Update new-address-api.md (#360)
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
- feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接
- feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色
- feat: 用户增加 `passkey` 登录方式, 用于用户登录, 无需输入密码
- feat: worker 增加 `DISABLE_ADMIN_PASSWORD_CHECK` 配置, 用于配置是否禁用 admin 控制台密码检查, 若你的网站只可私人访问,可通过此禁用检查
## v0.6.1
- pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355)
- fix: imap proxy server 不支持 密码 by @dreamhunter2333 (#356)
- worker 新增 `ANNOUNCEMENT` 配置, 用于配置公告信息 by @dreamhunter2333 (#357)
- fix: telegram bot 新建地址默认选择第一个域名 by @dreamhunter2333 (#358)
## v0.6.0
### Breaking Changes
@@ -313,7 +340,7 @@ The `mails` table will be discarded, and the `raw` text of the new `mail` will b
```bash
git checkout v0.2.0
cd worker
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
pnpm run deploy
cd ../frontend
pnpm run deploy

14
db/2024-08-10-patch.sql Normal file
View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
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);

View File

@@ -88,3 +88,18 @@ CREATE TABLE IF NOT EXISTS user_roles (
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
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);

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "0.6.0",
"version": "0.7.1",
"private": true,
"type": "module",
"scripts": {
@@ -17,6 +17,7 @@
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
},
"dependencies": {
"@simplewebauthn/browser": "^10.0.0",
"@unhead/vue": "^1.9.15",
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.11.0",

View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@simplewebauthn/browser':
specifier: ^10.0.0
version: 10.0.0
'@unhead/vue':
specifier: ^1.9.15
version: 1.9.15(vue@3.4.31(typescript@5.4.5))
@@ -1188,6 +1191,12 @@ packages:
cpu: [x64]
os: [win32]
'@simplewebauthn/browser@10.0.0':
resolution: {integrity: sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==}
'@simplewebauthn/types@10.0.0':
resolution: {integrity: sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==}
'@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -4075,6 +4084,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.18.0':
optional: true
'@simplewebauthn/browser@10.0.0':
dependencies:
'@simplewebauthn/types': 10.0.0
'@simplewebauthn/types@10.0.0': {}
'@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies:
ejs: 3.1.10

View File

@@ -6,7 +6,7 @@ import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables'
import Header from './views/Header.vue';
import Footer from './views/Footer.vue';
import { api } from './api'
const {
isDark, loading, useSideMargin, telegramApp, isTelegram
@@ -19,6 +19,13 @@ const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
onMounted(async () => {
try {
await api.getUserSettings();
} catch (error) {
console.error(error);
}
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
@@ -54,7 +61,7 @@ onMounted(async () => {
<n-config-provider :locale="localeConfig" :theme="theme">
<n-global-style />
<n-spin description="loading..." :show="loading">
<n-message-provider>
<n-message-provider container-style="margin-top: 20px;">
<n-grid x-gap="12" :cols="12">
<n-gi v-if="showSideMargin" span="1"></n-gi>
<n-gi :span="!showSideMargin ? 12 : 10">

View File

@@ -4,7 +4,7 @@ import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const {
loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings,
userOpenSettings, userSettings, announcement,
showAuth, adminAuth, showAdminAuth, userJwt
} = useGlobalState();
@@ -22,6 +22,7 @@ const apiFetch = async (path, options = {}) => {
data: options.body || null,
headers: {
'x-user-token': userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`,
@@ -56,6 +57,7 @@ const getOpenSettings = async (message) => {
const res = await api.fetch("/open_api/settings");
const domainLabels = res["domainLabels"] || [];
Object.assign(openSettings.value, {
...res,
title: res["title"] || "",
prefix: res["prefix"] || "",
minAddressLen: res["minAddressLen"] || 1,
@@ -81,6 +83,14 @@ const getOpenSettings = async (message) => {
if (openSettings.value.needAuth) {
showAuth.value = true;
}
if (openSettings.value.announcement && openSettings.value.announcement != announcement.value) {
announcement.value = openSettings.value.announcement;
message.info(announcement.value, {
showIcon: false,
duration: 0,
closable: true
});
}
} catch (error) {
message.error(error.message || "error");
}
@@ -118,7 +128,7 @@ const getUserSettings = async (message) => {
const res = await api.fetch("/user_api/settings")
Object.assign(userSettings.value, res)
} catch (error) {
message.error(error.message || "error");
message?.error(error.message || "error");
} finally {
userSettings.value.fetched = true;
}

View File

@@ -147,6 +147,7 @@ const refresh = async () => {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
loading.value = true;
data.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item);
@@ -161,6 +162,8 @@ const refresh = async () => {
} catch (error) {
message.error(error.message || "error");
console.error(error);
} finally {
loading.value = false;
}
};

View File

@@ -1,13 +1,15 @@
import { ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle } from '@vueuse/core'
import { computed, ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core'
export const useGlobalState = createGlobalState(
() => {
const isDark = useDark()
const toggleDark = useToggle(isDark)
const loading = ref(false);
const announcement = useLocalStorage('announcement', '');
const openSettings = ref({
title: '',
announcement: '',
prefix: '',
needAuth: false,
adminContact: '',
@@ -23,6 +25,7 @@ export const useGlobalState = createGlobalState(
cfTurnstileSiteKey: '',
enableWebhook: false,
isS3Enabled: false,
showGithub: true,
})
const settings = ref({
fetched: false,
@@ -72,9 +75,14 @@ export const useGlobalState = createGlobalState(
user_email: '',
/** @type {number} */
user_id: 0,
/** @type {boolean} */
is_admin: false,
/** @type {string | null} */
access_token: null,
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
user_role: null,
});
const showAdminPage = computed(() => !!adminAuth.value || userSettings.value.is_admin);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
return {
@@ -83,6 +91,7 @@ export const useGlobalState = createGlobalState(
loading,
settings,
sendMailModel,
announcement,
openSettings,
showAuth,
showAddressCredential,
@@ -105,6 +114,7 @@ export const useGlobalState = createGlobalState(
useSideMargin,
telegramApp,
isTelegram,
showAdminPage,
}
},
)

View File

@@ -21,7 +21,8 @@ import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
const {
adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage
} = useGlobalState()
const message = useMessage()
@@ -81,7 +82,7 @@ const { t } = useI18n({
});
onMounted(async () => {
if (!adminAuth.value) {
if (!showAdminPage.value) {
showAdminAuth.value = true;
return;
}
@@ -100,7 +101,7 @@ onMounted(async () => {
</n-button>
</template>
</n-modal>
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="account" :tab="t('account')">
<n-tabs type="bar" animated>
<n-tab-pane name="account" :tab="t('account')">

View File

@@ -17,8 +17,8 @@ import { getRouterPathWithLang } from '../utils'
const message = useMessage()
const {
toggleDark, isDark, isTelegram,
showAuth, adminAuth, auth, loading, openSettings
toggleDark, isDark, isTelegram, showAdminPage,
showAuth, auth, loading, openSettings, userSettings
} = useGlobalState()
const route = useRoute()
const router = useRouter()
@@ -134,7 +134,7 @@ const menuOptions = computed(() => [
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
}
),
show: !!adminAuth.value,
show: showAdminPage.value,
key: "admin"
},
{
@@ -192,6 +192,7 @@ const menuOptions = computed(() => [
icon: () => h(NIcon, { component: GithubAlt })
}
),
show: openSettings.value?.showGithub,
key: "github"
}
]);
@@ -223,6 +224,8 @@ const logoClick = async () => {
onMounted(async () => {
await api.getOpenSettings(message);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});
</script>

View File

@@ -9,8 +9,8 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
adminAuth, showAdminAuth, loading,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
showAdminAuth, loading, adminTab,
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
@@ -252,10 +252,6 @@ watch([page, pageSize], async () => {
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>

View File

@@ -11,20 +11,24 @@ const message = useMessage()
const { t } = useI18n({
messages: {
en: {
tip: 'You can manually input the following multiple select input',
save: 'Save',
successTip: 'Save Success',
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
address_block_list_placeholder: 'Please enter the keywords you want to block',
send_address_block_list: 'Address Block Keywords for send email',
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
fromBlockList: 'Block Keywords for receive email',
},
zh: {
tip: '您可以手动输入以下多选输入框',
save: '保存',
successTip: '保存成功',
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
send_address_block_list: '发送邮件地址屏蔽关键词',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
fromBlockList: '接收邮件地址屏蔽关键词',
}
}
});
@@ -32,6 +36,7 @@ const { t } = useI18n({
const addressBlockList = ref([])
const sendAddressBlockList = ref([])
const verifiedAddressList = ref([])
const fromBlockList = ref([])
const fetchData = async () => {
try {
@@ -39,6 +44,7 @@ const fetchData = async () => {
addressBlockList.value = res.blockList || []
sendAddressBlockList.value = res.sendBlockList || []
verifiedAddressList.value = res.verifiedAddressList || []
fromBlockList.value = res.fromBlockList || []
} catch (error) {
message.error(error.message || "error");
}
@@ -51,7 +57,8 @@ const save = async () => {
body: JSON.stringify({
blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || [],
verifiedAddressList: verifiedAddressList.value || []
verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [],
})
})
message.success(t('successTip'))
@@ -69,6 +76,9 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" style="margin-bottom: 10px;">
{{ t("tip") }}
</n-alert>
<n-form-item-row :label="t('address_block_list')">
<n-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
@@ -81,6 +91,9 @@ onMounted(async () => {
<n-select v-model:value="verifiedAddressList" filterable multiple tag
:placeholder="t('verified_address_list')" />
</n-form-item-row>
<n-form-item-row :label="t('fromBlockList')">
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')" />
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>

View File

@@ -6,10 +6,7 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const {
adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const { adminMailTabAddress } = useGlobalState()
const { t } = useI18n({
messages: {
@@ -48,13 +45,6 @@ const fetchMailData = async (limit, offset) => {
const deleteMail = async (curMailId) => {
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
};
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script>
<template>

View File

@@ -1,12 +1,7 @@
<script setup>
import { onMounted } from 'vue';
import { useGlobalState } from '../../store'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const { adminAuth, showAdminAuth } = useGlobalState()
const fetchMailUnknowData = async (limit, offset) => {
return await api.fetch(
`/admin/mails_unknow`
@@ -18,17 +13,10 @@ const fetchMailUnknowData = async (limit, offset) => {
const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script>
<template>
<div v-if="adminAuth" style="margin-top: 10px;">
<div style="margin-top: 10px;">
<MailBox :enableUserDeleteEmail="true" :fetchMailData="fetchMailUnknowData" :deleteMail="deleteMail" />
</div>
</template>

View File

@@ -1,12 +1,10 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { adminAuth, showAdminAuth } = useGlobalState()
const message = useMessage()
const cleanupModel = ref({
enableMailsAutoCleanup: false,
@@ -22,10 +20,10 @@ const cleanupModel = ref({
const { t } = useI18n({
messages: {
en: {
tip: 'Please input the cleanup days',
mailBoxLabel: 'Clean up days for mailbox',
mailUnknowLabel: "Clean up days for unknow receiver",
sendBoxLabel: "Clean up days for sendbox",
tip: 'Please input the days',
mailBoxLabel: 'Cleanup the inbox before n days',
mailUnknowLabel: "Cleanup the unknow mail before n days",
sendBoxLabel: "Cleanup the sendbox before n days",
cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup",
cleanupSuccess: "Cleanup success",
@@ -33,10 +31,10 @@ const { t } = useI18n({
cronTip: "Enable cron cleanup, need to configure [crons] in worker, please refer to the document",
},
zh: {
tip: '请输入清理天数',
mailBoxLabel: '收件箱清理天数',
mailUnknowLabel: "无收件人邮件清理天数",
sendBoxLabel: "发件箱清理天数",
tip: '请输入天数',
mailBoxLabel: '清理 n 天前的收件箱',
mailUnknowLabel: "清理 n 天前的无收件人邮件",
sendBoxLabel: "清理 n 天前的发件箱",
autoCleanup: "自动清理",
cleanupSuccess: "清理成功",
cleanupNow: "立即清理",
@@ -80,10 +78,6 @@ const save = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData();
})
</script>

View File

@@ -4,10 +4,8 @@ import { useI18n } from 'vue-i18n'
import { User, UserCheck, MailBulk } from '@vicons/fa'
import { SendOutlined } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { adminAuth } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
@@ -60,9 +58,6 @@ const fetchStatistics = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
return;
}
await fetchStatistics()
})
</script>

View File

@@ -6,7 +6,7 @@ import { api } from '../../api'
import { onMounted, watch } from 'vue';
import { processItem } from '../../utils/email-parser'
const { telegramApp } = useGlobalState()
const { telegramApp, loading } = useGlobalState()
const route = useRoute()
const curMail = ref({});
@@ -26,12 +26,16 @@ const fetchMailData = async () => {
mailId: route.query.mail_id
})
});
loading.value = true;
return await processItem(res);
}
catch (error) {
console.error(error);
return {};
}
finally {
loading.value = false;
}
};
onMounted(async () => {

View File

@@ -31,7 +31,8 @@ const { t } = useI18n({
onMounted(async () => {
await api.getUserOpenSettings(message);
await api.getUserSettings(message);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});
</script>

View File

@@ -1,18 +1,17 @@
<script setup>
import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref } from "vue";
import { onMounted, ref } from "vue";
import { useI18n } from 'vue-i18n'
import { api } from '../../api';
import { useGlobalState } from '../../store'
import { hashPassword } from '../../utils';
import { startAuthentication } from '@simplewebauthn/browser';
import Turnstile from '../../components/Turnstile.vue';
const { userJwt, userTab, userOpenSettings, openSettings } = useGlobalState()
const { userJwt, userOpenSettings, openSettings } = useGlobalState()
const message = useMessage();
const router = useRouter();
const { t } = useI18n({
messages: {
@@ -33,6 +32,7 @@ const { t } = useI18n({
pleaseInputCode: 'Please input code',
pleaseCompleteTurnstile: 'Please complete turnstile',
pleaseLogin: 'Please login',
loginWithPasskey: 'Login with Passkey',
},
zh: {
login: '登录',
@@ -51,6 +51,7 @@ const { t } = useI18n({
pleaseInputCode: '请输入验证码',
pleaseCompleteTurnstile: '请完成人机验证',
pleaseLogin: '请登录',
loginWithPasskey: '使用 Passkey 登录',
}
}
});
@@ -156,6 +157,33 @@ const emailSignup = async () => {
}
};
const passkeyLogin = async () => {
try {
const options = await api.fetch(`/user_api/passkey/authenticate_request`, {
method: 'POST',
body: JSON.stringify({
domain: location.hostname,
})
})
const credential = await startAuthentication(options)
// Send the result to the server and return the promise.
const res = await api.fetch(`/user_api/passkey/authenticate_response`, {
method: 'POST',
body: JSON.stringify({
origin: location.origin,
domain: location.hostname,
credential
})
})
userJwt.value = res.jwt;
location.reload();
} catch (e) {
console.error(e)
message.error(e.message)
}
};
onMounted(async () => {
});
@@ -178,6 +206,10 @@ onMounted(async () => {
<n-button @click="showModal = true" type="info" quaternary size="tiny">
{{ t('forgotPassword') }}
</n-button>
<n-divider />
<n-button @click="passkeyLogin" type="primary" block secondary strong>
{{ t('loginWithPasskey') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">

View File

@@ -1,16 +1,22 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { startRegistration } from '@simplewebauthn/browser';
import { NButton, NPopconfirm } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { userJwt, userSettings, } = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
const showCreatePasskey = ref(false)
const passkeyName = ref('')
const showPasskeyList = ref(false)
const showRenamePasskey = ref(false)
const currentPasskeyId = ref(null)
const currentPasskeyName = ref('')
const { t } = useI18n({
messages: {
@@ -18,11 +24,35 @@ const { t } = useI18n({
logout: 'Logout',
logoutConfirm: 'Are you sure you want to logout?',
passordTip: 'The server will only receive the hash value of the password, and will not receive the plaintext password, so it cannot view or retrieve your password. If the administrator enables email verification, you can reset the password in incognito mode',
createPasskey: 'Create Passkey',
showPasskeyList: 'Show Passkey List',
passkeyCreated: 'Passkey created successfully',
passkeyNamePlaceholder: 'Please enter the passkey name or leave it empty to generate a random one',
renamePasskey: 'Rename Passkey',
deletePasskey: 'Delete Passkey',
passkey_name: 'Passkey Name',
created_at: 'Created At',
updated_at: 'Updated At',
actions: 'Actions',
renamePasskey: 'Rename Passkey',
renamePasskeyNamePlaceholder: 'Please enter the new passkey name',
},
zh: {
logout: '退出登录',
logoutConfirm: '确定要退出登录吗?',
passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码',
createPasskey: '创建 Passkey',
showPasskeyList: '查看 Passkey 列表',
passkeyCreated: 'Passkey 创建成功',
passkeyNamePlaceholder: '请输入 Passkey 名称或者留空自动生成',
renamePasskey: '重命名 Passkey',
deletePasskey: '删除 Passkey',
passkey_name: 'Passkey 名称',
created_at: '创建时间',
updated_at: '更新时间',
actions: '操作',
renamePasskey: '重命名 Passkey',
renamePasskeyNamePlaceholder: '请输入新的 Passkey 名称',
}
}
});
@@ -33,17 +63,144 @@ const logout = async () => {
location.reload()
}
const fetchData = async () => {
const createPasskey = async () => {
try {
const options = await api.fetch(`/user_api/passkey/register_request`, {
method: 'POST',
body: JSON.stringify({
domain: location.hostname,
})
})
const credential = await startRegistration(options)
// Send the result to the server and return the promise.
await api.fetch(`/user_api/passkey/register_response`, {
method: 'POST',
body: JSON.stringify({
origin: location.origin,
passkey_name: passkeyName.value || (
(window.navigator.userAgentData?.platform || "Unknown")
+ ": " + Math.random().toString(36).substring(7)
),
credential
})
})
message.success(t('passkeyCreated'));
} catch (e) {
console.error(e)
message.error(e.message)
} finally {
passkeyName.value = ''
showCreatePasskey.value = false
}
}
onMounted(async () => {
await fetchData()
})
const passkeyColumns = [
{
title: "Passkey ID",
key: "passkey_id"
},
{
title: t('passkey_name'),
key: "passkey_name"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('updated_at'),
key: "updated_at"
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
[
h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => {
showRenamePasskey.value = true;
currentPasskeyId.value = row.passkey_id;
}
},
{ default: () => t('renamePasskey') }
),
h(NPopconfirm,
{
onPositiveClick: async () => {
try {
await api.fetch(`/user_api/passkey/${row.passkey_id}`, {
method: 'DELETE'
})
await fetchPasskeyList()
} catch (e) {
console.error(e)
message.error(e.message)
}
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "error",
},
{ default: () => t('deletePasskey') }
),
default: () => `${t('deletePasskey')}?`
}
),
]
])
}
}
]
const passkeyData = ref([])
const fetchPasskeyList = async () => {
try {
const data = await api.fetch(`/user_api/passkey`)
passkeyData.value = data
} catch (e) {
console.error(e)
message.error(e.message)
}
}
const renamePasskey = async () => {
try {
await api.fetch(`/user_api/passkey/rename`, {
method: 'POST',
body: JSON.stringify({
passkey_name: currentPasskeyName.value,
passkey_id: currentPasskeyId.value
})
})
await fetchPasskeyList()
} catch (e) {
console.error(e)
message.error(e.message)
} finally {
currentPasskeyName.value = ''
showRenamePasskey.value = false
}
}
</script>
<template>
<div class="center" v-if="userSettings.user_email">
<n-card :bordered="false" embedded>
<n-button @click="showPasskeyList = true; fetchPasskeyList();" secondary block strong>
{{ t('showPasskeyList') }}
</n-button>
<n-button @click="showCreatePasskey = true" type="primary" secondary block strong>
{{ t('createPasskey') }}
</n-button>
<n-alert :show-icon="false" :bordered="false">
<span>
{{ t('passordTip') }}
@@ -53,6 +210,25 @@ onMounted(async () => {
{{ t('logout') }}
</n-button>
</n-card>
<n-modal v-model:show="showCreatePasskey" preset="dialog" :title="t('createPasskey')">
<n-input v-model:value="passkeyName" :placeholder="t('passkeyNamePlaceholder')" />
<template #action>
<n-button :loading="loading" @click="createPasskey" size="small" tertiary type="primary">
{{ t('createPasskey') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showRenamePasskey" preset="dialog" :title="t('renamePasskey')">
<n-input v-model:value="currentPasskeyName" :placeholder="t('renamePasskeyNamePlaceholder')" />
<template #action>
<n-button :loading="loading" @click="renamePasskey" size="small" tertiary type="primary">
{{ t('renamePasskey') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showPasskeyList" preset="card" :title="t('showPasskeyList')">
<n-data-table :columns="passkeyColumns" :data="passkeyData" :bordered="false" embedded />
</n-modal>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
@@ -78,5 +254,6 @@ onMounted(async () => {
.n-button {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
proxy_url: str = "http://localhost:8787"
port: int = 8025
imap_port: int = 11143
basic_password: str = ""
class Config:
env_file = ".env"

View File

@@ -121,6 +121,7 @@ class SimpleMailbox:
f"{settings.proxy_url}/api/mails?limit={limit}&offset={start - 1}",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
@@ -147,6 +148,7 @@ class SimpleMailbox:
f"{settings.proxy_url}/api/sendbox?limit={limit}&offset={start - 1}",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)

View File

@@ -34,10 +34,10 @@ git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
```bash
# create a database, and copy the output to wrangler.toml in the next step
wrangler d1 create dev
wrangler d1 execute dev --file=db/schema.sql
wrangler d1 execute dev --file=db/schema.sql --remote
# schema update, if you have initialized the database before this date, you can execute this command to update
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql --remote
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql --remote
# create a namespace, and copy the output to wrangler.toml in the next step
wrangler kv:namespace create DEV
```
@@ -77,12 +77,15 @@ node_compat = true
# TITLE = "Custom Title" # The title of the site
PREFIX = "tmp" # The mailbox name prefix to be processed
# (min, max) length of the adderss, if not set, the default is (1, 30)
# ANNOUNCEMENT = "Custom Announcement"
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# If you want your site to be private, uncomment below and change your password
# PASSWORDS = ["123", "456"]
# admin console password, if not configured, access to the console is not allowed
# ADMIN_PASSWORDS = ["123", "456"]
# warning: no password or user check for admin portal
# DISABLE_ADMIN_PASSWORD_CHECK = false
# admin contact information. If not configured, it will not be displayed. Any string can be configured.
# ADMIN_CONTACT = "xx@xx.xxx"
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
@@ -90,6 +93,7 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all your domain name
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification)
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix
# USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },
@@ -107,13 +111,15 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# Footer text
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # Disable Show GitHub link
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# Turnstile verification configuration
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# telegram bot
# TG_MAX_ACCOUNTS = 5
# TG_MAX_ADDRESS = 5
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]

View File

@@ -9,7 +9,7 @@ cd worker
cp wrangler.toml.template wrangler.toml
# 创建 D1 并执行 schema.sql
wrangler d1 create dev
wrangler d1 execute dev --file=../db/schema.sql
wrangler d1 execute dev --file=../db/schema.sql --remote
```
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
@@ -25,6 +25,6 @@ wrangler d1 execute dev --file=../db/schema.sql
```bash
cd worker
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql --remote
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql --remote
```

View File

@@ -45,12 +45,15 @@ node_compat = true
# TITLE = "Custom Title" # 自定义网站标题
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
# (min, max) adderss的长度如果不设置默认为(1, 30)
# ANNOUNCEMENT = "Custom Announcement" # 自定义公告
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# 如果你想要你的网站私有,取消下面的注释,并修改密码
# PASSWORDS = ["123", "456"]
# admin 控制台密码, 不配置则不允许访问控制台
# ADMIN_PASSWORDS = ["123", "456"]
# 警告: 管理员控制台没有密码或用户检查
# DISABLE_ADMIN_PASSWORD_CHECK = false
# admin 联系方式,不配置则不显示,可配置任意字符串
# ADMIN_CONTACT = "xx@xx.xxx"
# DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 默认用户可用的域名(未登录或未分配角色的用户)
@@ -59,6 +62,8 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# 新用户默认角色, 仅在启用邮件验证时有效
# USER_DEFAULT_ROLE = "vip"
# admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# 用户角色配置, 如果 domains 为空将使用 default_domains
# 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀
# USER_ROLES = [
@@ -77,13 +82,15 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# 前端界面页脚文本
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # 是否显示 GitHub 链接
# 默认发送邮件余额,如果不设置,将为 0
# DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # 可以无限发送邮件的角色
# Turnstile 人机验证配置
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# telegram bot 最多绑定邮箱数量
# TG_MAX_ACCOUNTS = 5
# TG_MAX_ADDRESS = 5
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]

View File

@@ -1,7 +1,5 @@
# Admin 用户相关
默认不允许用户注册,可通过
## 用户管理页面
![admin-user-management](/feature/admin-user-management.png)

View File

@@ -1,7 +1,15 @@
# Admin 控制台
部署前端应用之后,访问 `/admin` 路径即可进入管理控制台。
> [!NOTE]
> 需要配置 `ADMIN_PASSWORDS` 或者 `ADMIN_USER_ROLE` 才可以访问 admin 控制台
> admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
需要在后端配置 `admin 控制台密码`, 不配置则不允许访问控制台。
部署前端应用之后,点击 左上角 logo 5 次 或者访问 `/admin` 路径即可进入管理控制台。
需要在后端配置 `ADMIN_PASSWORDS` 或者当前用户角色为 `ADMIN_USER_ROLE`, 则不允许访问控制台。
![admin](/feature/admin.png)
## 如果你的网站只可私人访问,可通过此禁用检查
`DISABLE_ADMIN_PASSWORD_CHECK = true`

View File

@@ -23,3 +23,70 @@ res = requests.post(
# 返回值 {"jwt": "<Jwt>"}
print(res.json())
```
# 批量创建随机用户名邮箱地址 API 示例
## 通过 admin API 批量新建邮箱地址
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
```python
import requests
import random
import string
from concurrent.futures import ThreadPoolExecutor, as_completed
def generate_random_name():
# 生成5位英文字符
letters1 = ''.join(random.choices(string.ascii_lowercase, k=5))
# 生成1-3个数字
numbers = ''.join(random.choices(string.digits, k=random.randint(1, 3)))
# 生成1-3个英文字符
letters2 = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))
# 组合成最终名称
return letters1 + numbers + letters2
def fetch_email_data(name):
try:
res = requests.post(
"https://<worker 域名>",
json={
"enablePrefix": True,
"name": name,
"domain": "<邮箱域名>",
},
headers={
'x-admin-auth': "<你的网站admin密码>",
"Content-Type": "application/json"
}
)
if res.status_code == 200:
response_data = res.json()
email = response_data.get("address", "无地址")
jwt = response_data.get("jwt", "无jwt")
return f"{email}----{jwt}\n"
else:
print(f"请求失败,状态码: {res.status_code}")
return None
except requests.RequestException as e:
print(f"请求出现错误: {e}")
return None
def generate_and_save_emails(num_emails):
with ThreadPoolExecutor(max_workers=30) as executor, open('email.txt', 'a') as file:
futures = [executor.submit(fetch_email_data, generate_random_name()) for _ in range(num_emails)]
for future in as_completed(futures):
result = future.result()
if result:
file.write(result)
# 生成10个邮箱并追加到现有文件
generate_and_save_emails(10)
```

View File

@@ -8,6 +8,10 @@
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
::: warning 注意
下面输入的是 `db/schema.sql` 的内容
:::
打开 `Console` 标签页,输入 `db/schema.sql` 的内容,点击 `Execute` 执行
![d1](/ui_install/d1-exec.png)

View File

@@ -13,6 +13,7 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20240620.0",
"@eslint/js": "8.56.0",
"@simplewebauthn/types": "^10.0.0",
"eslint": "8.56.0",
"globals": "^15.8.0",
"typescript-eslint": "^7.15.0",
@@ -21,6 +22,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.609.0",
"@aws-sdk/s3-request-presigner": "^3.609.0",
"@simplewebauthn/server": "^10.0.1",
"hono": "^4.4.12",
"mimetext": "^3.0.24",
"postal-mime": "^2.2.5",

126
worker/pnpm-lock.yaml generated
View File

@@ -19,6 +19,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.609.0
version: 3.609.0
'@simplewebauthn/server':
specifier: ^10.0.1
version: 10.0.1
hono:
specifier: ^4.4.12
version: 4.4.12
@@ -41,6 +44,9 @@ importers:
'@eslint/js':
specifier: 8.56.0
version: 8.56.0
'@simplewebauthn/types':
specifier: ^10.0.0
version: 10.0.0
eslint:
specifier: 8.56.0
version: 8.56.0
@@ -444,6 +450,9 @@ packages:
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@@ -471,6 +480,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@levischuck/tiny-cbor@0.2.2':
resolution: {integrity: sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -486,6 +498,21 @@ packages:
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@peculiar/asn1-android@2.3.10':
resolution: {integrity: sha512-z9Rx9cFJv7UUablZISe7uksNbFJCq13hO0yEAOoIpAymALTLlvUOSLnGiQS7okPaM5dP42oTLhezH6XDXRXjGw==}
'@peculiar/asn1-ecc@2.3.8':
resolution: {integrity: sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==}
'@peculiar/asn1-rsa@2.3.8':
resolution: {integrity: sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==}
'@peculiar/asn1-schema@2.3.8':
resolution: {integrity: sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==}
'@peculiar/asn1-x509@2.3.8':
resolution: {integrity: sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -497,6 +524,13 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@simplewebauthn/server@10.0.1':
resolution: {integrity: sha512-djNWcRn+H+6zvihBFJSpG3fzb0NQS9c/Mw5dYOtZ9H+oDw8qn9Htqxt4cpqRvSOAfwqP7rOvE9rwqVaoGGc3hg==}
engines: {node: '>=20.0.0'}
'@simplewebauthn/types@10.0.0':
resolution: {integrity: sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==}
'@smithy/abort-controller@3.1.1':
resolution: {integrity: sha512-MBJBiidoe+0cTFhyxT8g+9g7CeVccLM0IOKKUMCNQ1CNMJ/eIfoo0RTfVrXOONEI1UCN1W+zkiHSbzUNE9dZtQ==}
engines: {node: '>=16.0.0'}
@@ -825,6 +859,10 @@ packages:
as-table@1.0.55:
resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==}
asn1js@3.0.5:
resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==}
engines: {node: '>=12.0.0'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -900,6 +938,9 @@ packages:
core-js-pure@3.37.1:
resolution: {integrity: sha512-J/r5JTHSmzTxbiYYrzXg9w1VpqrYt+gexenBE9pugeyhwPZTAEJddyiReJWsLO6uNQ8xJZFbod6XC7KKwatCiA==}
cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -1157,6 +1198,10 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
ipaddr.js@2.2.0:
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
engines: {node: '>= 10'}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -1418,6 +1463,13 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
pvtsutils@1.3.5:
resolution: {integrity: sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==}
pvutils@1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2344,6 +2396,8 @@ snapshots:
'@fastify/busboy@2.1.1': {}
'@hexagon/base64@1.1.28': {}
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -2374,6 +2428,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15
'@levischuck/tiny-cbor@0.2.2': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2388,6 +2444,40 @@ snapshots:
'@one-ini/wasm@0.1.1': {}
'@peculiar/asn1-android@2.3.10':
dependencies:
'@peculiar/asn1-schema': 2.3.8
asn1js: 3.0.5
tslib: 2.6.3
'@peculiar/asn1-ecc@2.3.8':
dependencies:
'@peculiar/asn1-schema': 2.3.8
'@peculiar/asn1-x509': 2.3.8
asn1js: 3.0.5
tslib: 2.6.3
'@peculiar/asn1-rsa@2.3.8':
dependencies:
'@peculiar/asn1-schema': 2.3.8
'@peculiar/asn1-x509': 2.3.8
asn1js: 3.0.5
tslib: 2.6.3
'@peculiar/asn1-schema@2.3.8':
dependencies:
asn1js: 3.0.5
pvtsutils: 1.3.5
tslib: 2.6.3
'@peculiar/asn1-x509@2.3.8':
dependencies:
'@peculiar/asn1-schema': 2.3.8
asn1js: 3.0.5
ipaddr.js: 2.2.0
pvtsutils: 1.3.5
tslib: 2.6.3
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -2404,6 +2494,22 @@ snapshots:
domhandler: 5.0.3
selderee: 0.11.0
'@simplewebauthn/server@10.0.1':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.2
'@peculiar/asn1-android': 2.3.10
'@peculiar/asn1-ecc': 2.3.8
'@peculiar/asn1-rsa': 2.3.8
'@peculiar/asn1-schema': 2.3.8
'@peculiar/asn1-x509': 2.3.8
'@simplewebauthn/types': 10.0.0
cross-fetch: 4.0.0
transitivePeerDependencies:
- encoding
'@simplewebauthn/types@10.0.0': {}
'@smithy/abort-controller@3.1.1':
dependencies:
'@smithy/types': 3.3.0
@@ -2871,6 +2977,12 @@ snapshots:
dependencies:
printable-characters: 1.0.42
asn1js@3.0.5:
dependencies:
pvtsutils: 1.3.5
pvutils: 1.1.3
tslib: 2.6.3
balanced-match@1.0.2: {}
binary-extensions@2.3.0: {}
@@ -2948,6 +3060,12 @@ snapshots:
core-js-pure@3.37.1: {}
cross-fetch@4.0.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.3:
dependencies:
path-key: 3.1.1
@@ -3258,6 +3376,8 @@ snapshots:
ini@1.3.8: {}
ipaddr.js@2.2.0: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -3483,6 +3603,12 @@ snapshots:
punycode@2.3.1: {}
pvtsutils@1.3.5:
dependencies:
tslib: 2.6.3
pvutils@1.1.3: {}
queue-microtask@1.2.3: {}
react-dom@18.3.1(react@18.3.1):

View File

@@ -246,10 +246,12 @@ api.get('/admin/account_settings', async (c) => {
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || []
verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || []
})
} catch (error) {
console.error(error);
@@ -259,7 +261,7 @@ api.get('/admin/account_settings', async (c) => {
api.post('/admin/account_settings', async (c) => {
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const { blockList, sendBlockList, verifiedAddressList } = await c.req.json();
const { blockList, sendBlockList, verifiedAddressList, fromBlockList } = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text("Invalid blockList or sendBlockList", 400)
}
@@ -278,6 +280,12 @@ api.post('/admin/account_settings', async (c) => {
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
JSON.stringify(verifiedAddressList)
)
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text("Please enable KV to use fromBlockList", 400)
}
if (fromBlockList) {
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList || []))
}
return c.json({
success: true
})

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'
import { getDomains, getPasswords, getBooleanValue, getIntValue, getStringArray, getDefaultDomains } from './utils';
import { getDomains, getPasswords, getBooleanValue, getIntValue, getStringArray, getDefaultDomains, getStringValue } from './utils';
import { CONSTANTS } from './constants';
import { HonoCustomType } from './types';
import { isS3Enabled } from './mails_api/s3_attachment';
@@ -17,6 +17,7 @@ api.get('/open_api/settings', async (c) => {
}
return c.json({
"title": c.env.TITLE,
"announcement": getStringValue(c.env.ANNOUNCEMENT),
"prefix": c.env.PREFIX,
"minAddressLen": getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"maxAddressLen": getIntValue(c.env.MAX_ADDRESS_LEN, 30),
@@ -34,6 +35,7 @@ api.get('/open_api/settings', async (c) => {
"enableWebhook": getBooleanValue(c.env.ENABLE_WEBHOOK),
"isS3Enabled": isS3Enabled(c),
"version": CONSTANTS.VERSION,
"showGithub": !getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
});
})

View File

@@ -40,6 +40,11 @@ export const newAddress = async (
}
// check domain
const allowDomains = checkAllowDomains ? await getAllowDomains(c) : getDomains(c);
// if domain is not set, use the first domain
if (!domain && allowDomains.length > 0) {
domain = allowDomains[0];
}
// check domain is valid
if (!domain || !allowDomains.includes(domain)) {
throw new Error("Invalid domain")
}
@@ -78,7 +83,7 @@ export const cleanup = async (
cleanType: string | undefined | null,
cleanDays: number | undefined | null
): Promise<boolean> => {
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
if (!cleanType || typeof cleanDays !== 'number' || cleanDays < 0 || cleanDays > 30) {
throw new Error("Invalid cleanType or cleanDays")
}
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);

View File

@@ -1,5 +1,5 @@
export const CONSTANTS = {
VERSION: 'v0.6.0',
VERSION: 'v0.7.1',
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
@@ -13,4 +13,5 @@ export const CONSTANTS = {
TG_KV_SETTINGS_KEY: "temp-mail-telegram-settings",
WEBHOOK_KV_SETTINGS_KEY: "temp-mail-webhook-settings",
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
EMAIL_KV_BLACK_LIST: "temp-mail-email-black-list",
}

View File

@@ -0,0 +1,16 @@
import { CONSTANTS } from "../constants";
import { Bindings } from "../types";
export const isBlocked = async (from: string, env: Bindings): Promise<boolean> => {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => from.includes(word))) {
return true;
}
if (!env.KV) {
return false;
}
const blockList = await env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') || [];
if (blockList.some(word => from.includes(word))) {
return true;
}
return false;
}

View File

@@ -5,11 +5,12 @@ import { sendMailToTelegram } from "../telegram_api";
import { Bindings, HonoCustomType } from "../types";
import { auto_reply } from "./auto_reply";
import { trigerWebhook } from "../mails_api/webhook_settings";
import { isBlocked } from "./black_list";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
message.setReject("Missing from address");
if (await isBlocked(message.from, env)) {
message.setReject("Reject from address");
console.log(`Reject message from ${message.from} to ${message.to}`);
return;
}

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { HonoCustomType } from "../types";
import { getBooleanValue, getJsonSetting, checkCfTurnstile } from '../utils';
import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue } from '../utils';
import { newAddress, handleListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains } from '../common'
import { CONSTANTS } from '../constants'
import auto_reply from './auto_reply'
@@ -49,6 +49,7 @@ api.delete('/api/mails/:id', async (c) => {
api.get('/api/settings', async (c) => {
const { address, address_id } = c.get("jwtPayload")
const user_role = c.get("userRolePayload")
if (address_id && address_id > 0) {
try {
const db_address_id = await c.env.DB.prepare(
@@ -82,7 +83,8 @@ api.get('/api/settings', async (c) => {
} catch (e) {
console.warn("Failed to update address")
}
const balance = await c.env.DB.prepare(
const is_no_limit_send_balance = user_role && user_role === getStringValue(c.env.NO_LIMIT_SEND_ROLE);
const balance = is_no_limit_send_balance ? 99999 : await c.env.DB.prepare(
`SELECT balance FROM address_sender where address = ? and enabled = 1`
).bind(address).first("balance");
return c.json({

View File

@@ -4,7 +4,7 @@ import { createMimeMessage } from 'mimetext';
import { Resend } from 'resend';
import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains, getIntValue, getBooleanValue } from '../utils';
import { getJsonSetting, getDomains, getIntValue, getBooleanValue, getStringValue } from '../utils';
import { GeoData } from '../models'
import { handleListQuery } from '../common'
import { HonoCustomType } from '../types';
@@ -105,13 +105,17 @@ export const sendMail = async (
if (!domains.includes(mailDomain)) {
throw new Error("Invalid domain")
}
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
const user_role = c.get("userRolePayload");
const is_no_limit_send_balance = user_role && user_role === getStringValue(c.env.NO_LIMIT_SEND_ROLE);
if (!is_no_limit_send_balance) {
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1`
).bind(address).first<number>("balance");
if (!balance || balance <= 0) {
throw new Error("No balance")
).bind(address).first<number>("balance");
if (!balance || balance <= 0) {
throw new Error("No balance")
}
}
const {
from_name, to_mail, to_name,
@@ -154,7 +158,7 @@ export const sendMail = async (
throw new Error("Please enable resend or verified address list")
}
// update balance
if (!sendByVerifiedAddressList) {
if (!sendByVerifiedAddressList && !is_no_limit_send_balance) {
try {
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET balance = balance - 1 where address = ?`

View File

@@ -1,3 +1,18 @@
import type {
AuthenticatorTransportFuture,
CredentialDeviceType,
Base64URLString,
} from '@simplewebauthn/types';
export type Passkey = {
id: Base64URLString;
publicKey: string;
counter: number;
deviceType: CredentialDeviceType;
backedUp: boolean;
transports?: AuthenticatorTransportFuture[];
};
export class AdminWebhookSettings {
allowList: string[];

View File

@@ -13,16 +13,19 @@ export type Bindings = {
// config
TITLE: string | undefined
ANNOUNCEMENT: string | undefined | null
PREFIX: string | undefined
MIN_ADDRESS_LEN: string | number | undefined
MAX_ADDRESS_LEN: string | number | undefined
DEFAULT_DOMAINS: string | string[] | undefined
DOMAINS: string | string[] | undefined
ADMIN_USER_ROLE: string | undefined
USER_DEFAULT_ROLE: string | UserRole | undefined
USER_ROLES: string | UserRole[] | undefined
DOMAIN_LABELS: string | string[] | undefined
PASSWORDS: string | string[] | undefined
ADMIN_PASSWORDS: string | string[] | undefined
DISABLE_ADMIN_PASSWORD_CHECK: string | boolean | undefined
JWT_SECRET: string
BLACK_LIST: string | undefined
ENABLE_AUTO_REPLY: string | boolean | undefined
@@ -31,8 +34,10 @@ export type Bindings = {
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ENABLE_INDEX_ABOUT: string | boolean | undefined
DEFAULT_SEND_BALANCE: number | string | undefined
NO_LIMIT_SEND_ROLE: string | undefined | null
ADMIN_CONTACT: string | undefined
COPYRIGHT: string | undefined
DISABLE_SHOW_GITHUB: string | boolean | undefined
FORWARD_ADDRESS_LIST: string | string[] | undefined
// s3 config
@@ -69,7 +74,8 @@ type UserPayload = {
type Variables = {
userPayload: UserPayload,
jwtPayload: JwtPayload
userRolePayload: string | undefined | null,
jwtPayload: JwtPayload,
}
type HonoCustomType = {

View File

@@ -4,15 +4,30 @@ import { HonoCustomType } from '../types';
import settings from './settings';
import user from './user';
import bind_address from './bind_address';
import passkey from './passkey';
export const api = new Hono<HonoCustomType>();
// settings api
api.get('/user_api/open_settings', settings.openSettings);
api.get('/user_api/settings', settings.settings);
// user api
api.post('/user_api/login', user.login);
api.post('/user_api/verify_code', user.verifyCode);
api.post('/user_api/register', user.register);
// bind address api
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
api.post('/user_api/bind_address', bind_address.bind);
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
api.post('/user_api/unbind_address', bind_address.unbind);
// passkey api
api.get('/user_api/passkey', passkey.getPassKeys);
api.post('/user_api/passkey/rename', passkey.renamePassKey);
api.delete('/user_api/passkey/:passkey_id', passkey.deletePassKey);
api.post('/user_api/passkey/register_request', passkey.registerRequest);
api.post('/user_api/passkey/register_response', passkey.registerResponse);
api.post('/user_api/passkey/authenticate_request', passkey.authenticateRequest);
api.post('/user_api/passkey/authenticate_response', passkey.authenticateResponse);

View File

@@ -0,0 +1,204 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { HonoCustomType } from '../types';
import { Passkey } from '../models';
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
export default {
getPassKeys: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { results } = await c.env.DB.prepare(
`SELECT passkey_name, passkey_id, created_at, updated_at FROM user_passkeys WHERE user_id = ?`
).bind(user.user_id).all<Record<string, string>>();
return c.json(results);
},
renamePassKey: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { passkey_id, passkey_name } = await c.req.json();
if (!passkey_name || passkey_name.length > 255) {
return c.text("Invalid passkey name", 400);
}
const { success } = await c.env.DB.prepare(
`UPDATE user_passkeys SET passkey_name = ? WHERE user_id = ? AND passkey_id = ?`
).bind(passkey_name, user.user_id, passkey_id).run();
return c.json({ success });
},
deletePassKey: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { passkey_id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM user_passkeys WHERE user_id = ? AND passkey_id = ?`
).bind(user.user_id, passkey_id).run();
return c.json({ success });
},
registerRequest: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { domain } = await c.req.json();
const { results } = await c.env.DB.prepare(
`SELECT passkey FROM user_passkeys WHERE user_id = ?`
).bind(user.user_id).all<Record<string, string>>();
const excludeCredentials = results
.map((record: any) => JSON.parse(record.passkey) as Passkey)
.map((passkey: Passkey) => ({
id: passkey.id,
transports: passkey.transports,
}));
// create challenge with 1 hour expiration
const challenge = await Jwt.sign({
user_email: user.user_email,
user_id: user.user_id,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
// Use SimpleWebAuthn's handy function to create registration options.
const options = await generateRegistrationOptions({
rpName: c.env.TITLE || "Temp Mail",
rpID: domain,
userID: new TextEncoder().encode(user.user_id.toString()),
userName: user.user_email,
userDisplayName: user.user_email,
attestationType: 'none',
excludeCredentials: excludeCredentials,
challenge: challenge,
});
return c.json(options);
},
registerResponse: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { credential, origin, passkey_name } = await c.req.json();
// Verify the registration response
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: async (challenge: string) => {
const payload = await Jwt.verify(atob(challenge), c.env.JWT_SECRET, "HS256");
if (!payload || !payload.iat) return false;
// check iad is not older than 5 minutes
if (Math.floor(Date.now() / 1000) - payload.iat > 300) return false;
if (payload.user_id !== user.user_id) return false;
return true;
},
expectedOrigin: origin,
requireUserVerification: true,
});
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return c.text("Registration failed", 400);
}
const {
credentialID, credentialPublicKey,
counter, credentialDeviceType, credentialBackedUp,
} = registrationInfo;
// Base64URL encode ArrayBuffers.
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
const newPasskey: Passkey = {
id: credentialID,
publicKey: base64PublicKey,
counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: credential?.response?.transports,
};
// Store the credential ID in the database
const { success } = await c.env.DB.prepare(
`INSERT INTO user_passkeys (user_id, passkey_name, passkey_id, passkey, counter) VALUES (?, ?, ?, ?, ?)`
).bind(user.user_id, passkey_name, credentialID, JSON.stringify(newPasskey), counter).run();
return c.json({ success });
},
authenticateRequest: async (c: Context<HonoCustomType>) => {
const { domain } = await c.req.json();
const challenge = await Jwt.sign({
domain,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
const options: PublicKeyCredentialRequestOptionsJSON = await generateAuthenticationOptions({
rpID: domain,
challenge: challenge,
allowCredentials: [],
});
return c.json(options);
},
authenticateResponse: async (c: Context<HonoCustomType>) => {
const { domain, credential, origin } = await c.req.json();
const passkey_id = credential?.id;
if (!passkey_id) {
return c.text("Invalid request", 400);
}
const { user_id, counter, passkey } = await c.env.DB.prepare(
`SELECT user_id, counter, passkey FROM user_passkeys WHERE passkey_id = ?`
).bind(passkey_id).first<{
counter: number; passkey: string; user_id: number;
}>() || {};
if (!passkey) {
return c.text("Passkey not found", 404);
}
const passkeyData = JSON.parse(passkey) as Passkey;
// Verify the registration response
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: async (challenge: string) => {
const payload = await Jwt.verify(atob(challenge), c.env.JWT_SECRET, "HS256");
if (!payload || !payload.iat) return false;
// check iad is not older than 5 minutes
if (Math.floor(Date.now() / 1000) - payload.iat > 300) return false;
return true;
},
expectedOrigin: origin,
expectedRPID: domain,
authenticator: {
credentialID: passkeyData.id,
credentialPublicKey: isoBase64URL.toBuffer(passkeyData.publicKey),
counter: counter || passkeyData.counter,
transports: passkeyData.transports,
},
});
const { verified, authenticationInfo } = verification;
if (!verified) {
return c.text("Authentication failed", 400);
}
if (authenticationInfo) {
const { newCounter } = authenticationInfo;
// Update the counter in the database
await c.env.DB.prepare(
`UPDATE user_passkeys SET counter = ? WHERE passkey_id = ?`
).bind(newCounter, passkey_id).run();
}
// update passkey updated_at
await c.env.DB.prepare(
`UPDATE user_passkeys SET updated_at = datetime('now') WHERE passkey_id = ?`
).bind(passkey_id).run();
// return jwt
const { user_email } = await c.env.DB.prepare(
`SELECT user_email FROM users WHERE id = ?`
).bind(user_id).first<{ user_email: string }>() || {};
if (!user_email) {
return c.text("User not found", 404);
}
// create jwt
const jwt = await Jwt.sign({
user_email: user_email,
user_id: user_id,
// 30 days expire in seconds
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
return c.json({
jwt: jwt
})
},
}

View File

@@ -5,6 +5,7 @@ import { UserSettings } from "../models";
import { getJsonSetting, getUserRoles } from "../utils"
import { CONSTANTS } from "../constants";
import { commonGetUserRole } from "../common";
import { Jwt } from "hono/utils/jwt";
export default {
openSettings: async (c: Context<HonoCustomType>) => {
@@ -25,8 +26,23 @@ export default {
return c.text("User not found", 400);
}
const user_role = await commonGetUserRole(c, db_user_id);
const is_admin = (
c.env.ADMIN_USER_ROLE
&&
c.env.ADMIN_USER_ROLE === user_role?.role
);
const access_token = is_admin ? await Jwt.sign({
user_email: user.user_email,
user_id: user.user_id,
user_role: user_role?.role,
iat: Math.floor(Date.now() / 1000),
// 1 hour
exp: Math.floor(Date.now() / 1000) + 3600,
}, c.env.JWT_SECRET, "HS256") : null;
return c.json({
...user,
is_admin: is_admin,
access_token: access_token,
user_role: user_role
});
},

View File

@@ -59,7 +59,6 @@ export const getBooleanValue = (
if (typeof value === "string") {
return value === "true";
}
console.error(`Failed to parse boolean value: ${value}`);
return false;
}

View File

@@ -75,6 +75,26 @@ const checkUserPayload = async (
}
}
const checkoutUserRolePayload = async (
c: Context<HonoCustomType>
): Promise<void> => {
try {
const token = c.req.raw.headers.get("x-user-access-token");
if (!token) return;
const payload = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
// check expired
if (!payload.exp) return;
// exp is in seconds
if (payload.exp < Math.floor(Date.now() / 1000)) {
return;
}
if (typeof payload?.user_role !== "string") return;
c.set("userRolePayload", payload.user_role);
} catch (e) {
console.error(e);
}
}
// api auth
app.use('/api/*', async (c, next) => {
// check header x-custom-auth
@@ -90,6 +110,11 @@ app.use('/api/*', async (c, next) => {
await next();
return;
}
if (c.req.path.startsWith("/api/settings")
|| c.req.path.startsWith("/api/send_mail")
) {
await checkoutUserRolePayload(c);
}
return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
});
// user_api auth
@@ -99,6 +124,7 @@ app.use('/user_api/*', async (c, next) => {
|| c.req.path.startsWith("/user_api/register")
|| c.req.path.startsWith("/user_api/login")
|| c.req.path.startsWith("/user_api/verify_code")
|| c.req.path.startsWith("/user_api/passkey/authenticate_")
) {
await next();
return;
@@ -127,6 +153,7 @@ app.use('/user_api/*', async (c, next) => {
});
// admin auth
app.use('/admin/*', async (c, next) => {
// check header x-admin-auth
const adminPasswords = getAdminPasswords(c);
if (adminPasswords && adminPasswords.length > 0) {
@@ -136,6 +163,33 @@ app.use('/admin/*', async (c, next) => {
return;
}
}
// check if user is admin
const access_token = c.req.raw.headers.get("x-user-access-token");
if (c.env.ADMIN_USER_ROLE && access_token) {
try {
const payload = await Jwt.verify(access_token, c.env.JWT_SECRET, "HS256");
// check expired
if (!payload.exp) return c.text("Invalid Token", 401);
// exp is in seconds
if (payload.exp < Math.floor(Date.now() / 1000)) {
return c.text("Token Expired", 401)
}
if (payload.user_role !== c.env.ADMIN_USER_ROLE) {
return c.text("Need Admin Role", 401)
}
await next();
return;
} catch (e) {
console.error(e);
}
}
// disable admin api check
if (getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)) {
await next();
return;
}
return c.text("Need Admin Password", 401)
});

View File

@@ -17,6 +17,7 @@ node_compat = true
[vars]
# TITLE = "Custom Title" # custom title
# ANNOUNCEMENT = "Custom Announcement"
PREFIX = "tmp"
# (min, max) length of the adderss, if not set, the default is (1, 30)
# MIN_ADDRESS_LEN = 1
@@ -25,6 +26,8 @@ PREFIX = "tmp"
# PASSWORDS = ["123", "456"]
# For admin panel
# ADMIN_PASSWORDS = ["123", "456"]
# warning: no password or user check for admin portal
# DISABLE_ADMIN_PASSWORD_CHECK = false
# ADMIN CONTACT, CAN BE ANY STRING
# ADMIN_CONTACT = "xx@xx.xxx"
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
@@ -32,6 +35,7 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all domain names
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification)
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix
# USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },
@@ -49,13 +53,15 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# Footer text
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# Turnstile verification
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# telegram bot
# TG_MAX_ACCOUNTS = 5
# TG_MAX_ADDRESS = 5
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]