mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-07-09 06:23:07 +08:00
feat: add CF Turnstile when new address (#200)
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
- 修复 Admin 删除邮件报错
|
- 修复 Admin 删除邮件报错
|
||||||
- UI: 回复邮件按钮, 引用原始邮件文本
|
- UI: 回复邮件按钮, 引用原始邮件文本
|
||||||
- 添加发送邮件地址黑名单
|
- 添加发送邮件地址黑名单
|
||||||
|
- 添加 `CF Turnstile` 人机验证配置
|
||||||
|
|
||||||
## v0.3.2
|
## v0.3.2
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="icon" href="/logo.png" sizes="any">
|
<link rel="icon" href="/logo.png" sizes="any">
|
||||||
<link rel="apple-touch-icon" href="/logo.png">
|
<link rel="apple-touch-icon" href="/logo.png">
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const getOpenSettings = async (message) => {
|
|||||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||||
enableAutoReply: res["enableAutoReply"] || false,
|
enableAutoReply: res["enableAutoReply"] || false,
|
||||||
copyright: res["copyright"] || openSettings.value.copyright,
|
copyright: res["copyright"] || openSettings.value.copyright,
|
||||||
|
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
|
||||||
});
|
});
|
||||||
if (openSettings.value.needAuth) {
|
if (openSettings.value.needAuth) {
|
||||||
showAuth.value = true;
|
showAuth.value = true;
|
||||||
|
|||||||
88
frontend/src/components/Turnstile.vue
Normal file
88
frontend/src/components/Turnstile.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, defineModel, onMounted } from "vue";
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useGlobalState } from '../store'
|
||||||
|
const { localeCache, openSettings, isDark } = useGlobalState()
|
||||||
|
|
||||||
|
const cfToken = defineModel('value')
|
||||||
|
|
||||||
|
const { t } = useI18n({
|
||||||
|
locale: localeCache.value || 'zh',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
refresh: 'Refresh'
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
refresh: '刷新'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfTurnstileId = ref("")
|
||||||
|
const turnstileLoading = ref(false)
|
||||||
|
|
||||||
|
const checkCfTurnstile = async (remove) => {
|
||||||
|
if (!openSettings.value.cfTurnstileSiteKey) return;
|
||||||
|
turnstileLoading.value = true;
|
||||||
|
try {
|
||||||
|
let container = document.getElementById("cf-turnstile");
|
||||||
|
let count = 100;
|
||||||
|
while (!container && count-- > 0) {
|
||||||
|
container = document.getElementById("cf-turnstile");
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
count = 100;
|
||||||
|
while (!window.turnstile && count-- > 0) {
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
if (remove && cfTurnstileId.value) {
|
||||||
|
window.turnstile.remove(cfTurnstileId.value);
|
||||||
|
}
|
||||||
|
cfTurnstileId.value = window.turnstile.render(
|
||||||
|
"#cf-turnstile",
|
||||||
|
{
|
||||||
|
sitekey: openSettings.value.cfTurnstileSiteKey,
|
||||||
|
language: localeCache.value == 'zh' ? 'zh-CN' : 'en-US',
|
||||||
|
theme: isDark.value ? 'dark' : 'light',
|
||||||
|
callback: function (token) {
|
||||||
|
cfToken.value = token;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
turnstileLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isDark, async (isDark) => {
|
||||||
|
checkCfTurnstile(true)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
cfToken.value = "";
|
||||||
|
checkCfTurnstile(true);
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="openSettings.cfTurnstileSiteKey" class="center">
|
||||||
|
<n-spin description="loading..." :show="turnstileLoading">
|
||||||
|
<n-form-item-row>
|
||||||
|
<div id="cf-turnstile"></div>
|
||||||
|
<n-button text @click="checkCfTurnstile(true)">
|
||||||
|
{{ t('refresh') }}
|
||||||
|
</n-button>
|
||||||
|
</n-form-item-row>
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-button {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { createGlobalState, useStorage } from '@vueuse/core'
|
import { createGlobalState, useStorage, useDark, useToggle } from '@vueuse/core'
|
||||||
import { useDark, useToggle } from '@vueuse/core'
|
|
||||||
|
|
||||||
export const useGlobalState = createGlobalState(
|
export const useGlobalState = createGlobalState(
|
||||||
() => {
|
() => {
|
||||||
@@ -16,6 +15,7 @@ export const useGlobalState = createGlobalState(
|
|||||||
enableAutoReply: false,
|
enableAutoReply: false,
|
||||||
domains: [],
|
domains: [],
|
||||||
copyright: 'Dream Hunter',
|
copyright: 'Dream Hunter',
|
||||||
|
cfTurnstileSiteKey: '',
|
||||||
})
|
})
|
||||||
const settings = ref({
|
const settings = ref({
|
||||||
fetched: false,
|
fetched: false,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import AdminContact from './admin/AdminContact.vue'
|
import AdminContact from './admin/AdminContact.vue'
|
||||||
|
import Turnstile from '../components/Turnstile.vue'
|
||||||
|
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
jwt, localeCache, loading, openSettings, showPassword
|
jwt, localeCache, loading, openSettings, showPassword
|
||||||
@@ -15,6 +18,7 @@ const tabValue = ref('signin')
|
|||||||
const password = ref('')
|
const password = ref('')
|
||||||
const emailName = ref("")
|
const emailName = ref("")
|
||||||
const emailDomain = ref("")
|
const emailDomain = ref("")
|
||||||
|
const cfToken = ref("")
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
if (!password.value) {
|
if (!password.value) {
|
||||||
@@ -81,11 +85,14 @@ const generateName = async () => {
|
|||||||
|
|
||||||
const newEmail = async () => {
|
const newEmail = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.fetch(
|
const res = await api.fetch(`/api/new_address`, {
|
||||||
`/api/new_address`
|
method: "POST",
|
||||||
+ `?name=${emailName.value || ''}`
|
body: JSON.stringify({
|
||||||
+ `&domain=${emailDomain.value || ''}`
|
name: emailName.value,
|
||||||
);
|
domain: emailDomain.value,
|
||||||
|
cf_token: cfToken.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
jwt.value = res["jwt"];
|
jwt.value = res["jwt"];
|
||||||
await api.getSettings();
|
await api.getSettings();
|
||||||
showPassword.value = true;
|
showPassword.value = true;
|
||||||
@@ -95,7 +102,7 @@ const newEmail = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
|
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0]?.value : "";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -136,6 +143,7 @@ onMounted(async () => {
|
|||||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||||
:options="openSettings.domains" />
|
:options="openSettings.domains" />
|
||||||
</n-input-group>
|
</n-input-group>
|
||||||
|
<Turnstile v-model:value="cfToken" />
|
||||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
|
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
|
||||||
{{ t('ok') }}
|
{{ t('ok') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const { t } = useI18n({
|
|||||||
zh: {
|
zh: {
|
||||||
save: '保存',
|
save: '保存',
|
||||||
successTip: '保存成功',
|
successTip: '保存成功',
|
||||||
address_block_list: '用户地址屏蔽关键词(管理员可跳过检查)',
|
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
|
||||||
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
|
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
|
||||||
send_address_block_list: '发送邮件地址屏蔽关键词',
|
send_address_block_list: '发送邮件地址屏蔽关键词',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const { t } = useI18n({
|
|||||||
tip: '请输入清理天数',
|
tip: '请输入清理天数',
|
||||||
mailBoxLabel: '收件箱清理天数',
|
mailBoxLabel: '收件箱清理天数',
|
||||||
mailUnknowLabel: "无收件人邮件清理天数",
|
mailUnknowLabel: "无收件人邮件清理天数",
|
||||||
addressUnActiveLabel: "未激活地址清理天数",
|
addressUnActiveLabel: "未活跃地址清理天数",
|
||||||
sendBoxLabel: "发件箱清理天数",
|
sendBoxLabel: "发件箱清理天数",
|
||||||
autoCleanup: "自动清理",
|
autoCleanup: "自动清理",
|
||||||
cleanupSuccess: "清理成功",
|
cleanupSuccess: "清理成功",
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ const { t } = useI18n({
|
|||||||
locale: localeCache.value || 'zh',
|
locale: localeCache.value || 'zh',
|
||||||
messages: {
|
messages: {
|
||||||
en: {
|
en: {
|
||||||
userCount: 'User Count',
|
userCount: 'Account Count',
|
||||||
activeUser: '7 days Active User',
|
activeUser: '7 days Active Mail Account',
|
||||||
mailCount: 'Mail Count',
|
mailCount: 'Mail Count',
|
||||||
sendMailCount: 'Send Mail Count'
|
sendMailCount: 'Send Mail Count'
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
userCount: '用户总数',
|
userCount: '地址总数',
|
||||||
activeUser: '周活跃用户',
|
activeUser: '周活跃邮箱地址',
|
||||||
mailCount: '邮件总数',
|
mailCount: '邮件总数',
|
||||||
sendMailCount: '发送邮件总数'
|
sendMailCount: '发送邮件总数'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ ENABLE_AUTO_REPLY = false
|
|||||||
# COPYRIGHT = "Dream Hunter"
|
# COPYRIGHT = "Dream Hunter"
|
||||||
# default send balance, if not set, it will be 0
|
# default send balance, if not set, it will be 0
|
||||||
# DEFAULT_SEND_BALANCE = 1
|
# DEFAULT_SEND_BALANCE = 1
|
||||||
|
# Turnstile verification configuration
|
||||||
|
# CF_TURNSTILE_SITE_KEY = ""
|
||||||
|
# CF_TURNSTILE_SECRET_KEY = ""
|
||||||
# dkim config
|
# dkim config
|
||||||
# DKIM_SELECTOR = "mailchannels" # Refer to the DKIM section mailchannels._domainkey for mailchannels
|
# DKIM_SELECTOR = "mailchannels" # Refer to the DKIM section mailchannels._domainkey for mailchannels
|
||||||
# DKIM_PRIVATE_KEY = "" # Refer to the contents of priv_key.txt in the DKIM section
|
# DKIM_PRIVATE_KEY = "" # Refer to the contents of priv_key.txt in the DKIM section
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ ENABLE_AUTO_REPLY = false
|
|||||||
# COPYRIGHT = "Dream Hunter"
|
# COPYRIGHT = "Dream Hunter"
|
||||||
# 默认发送邮件余额,如果不设置,将为 0
|
# 默认发送邮件余额,如果不设置,将为 0
|
||||||
# DEFAULT_SEND_BALANCE = 1
|
# DEFAULT_SEND_BALANCE = 1
|
||||||
|
# Turnstile 人机验证配置
|
||||||
|
# CF_TURNSTILE_SITE_KEY = ""
|
||||||
|
# CF_TURNSTILE_SECRET_KEY = ""
|
||||||
# dkim config
|
# dkim config
|
||||||
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
|
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
|
||||||
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
|
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDomains, getPasswords, getBooleanValue, getJsonSetting
|
getDomains, getPasswords, getBooleanValue, getJsonSetting,
|
||||||
|
checkCfTurnstile
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { newAddress } from './common'
|
import { newAddress } from './common'
|
||||||
import { CONSTANTS } from './constants'
|
import { CONSTANTS } from './constants'
|
||||||
@@ -116,14 +117,21 @@ api.get('/open_api/settings', async (c) => {
|
|||||||
"enableUserDeleteEmail": getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
"enableUserDeleteEmail": getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
||||||
"enableAutoReply": getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
"enableAutoReply": getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
||||||
"copyright": c.env.COPYRIGHT,
|
"copyright": c.env.COPYRIGHT,
|
||||||
|
"cfTurnstileSiteKey": c.env.CF_TURNSTILE_SITE_KEY,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
api.get('/api/new_address', async (c) => {
|
api.post('/api/new_address', async (c) => {
|
||||||
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
|
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
|
||||||
return c.text("New address is disabled", 403)
|
return c.text("New address is disabled", 403)
|
||||||
}
|
}
|
||||||
let { name, domain } = c.req.query();
|
let { name, domain, cf_token } = await c.req.json();
|
||||||
|
// check cf turnstile
|
||||||
|
try {
|
||||||
|
await checkCfTurnstile(c, cf_token);
|
||||||
|
} catch (error) {
|
||||||
|
return c.text("Failed to check cf turnstile", 500)
|
||||||
|
}
|
||||||
// if no name, generate random name
|
// if no name, generate random name
|
||||||
if (!name) {
|
if (!name) {
|
||||||
name = Math.random().toString(36).substring(2, 15);
|
name = Math.random().toString(36).substring(2, 15);
|
||||||
|
|||||||
@@ -129,3 +129,24 @@ export const sendAdminInternalMail = async (c, toMail, subject, text) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const checkCfTurnstile = async (c, token) => {
|
||||||
|
if (!c.env.CF_TURNSTILE_SITE_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||||
|
let formData = new FormData();
|
||||||
|
formData.append('secret', c.env.CF_TURNSTILE_SECRET_KEY);
|
||||||
|
formData.append('response', token);
|
||||||
|
formData.append('remoteip', reqIp);
|
||||||
|
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
const result = await fetch(url, {
|
||||||
|
body: formData,
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const captchaRes = await result.json();
|
||||||
|
if (!captchaRes.success) {
|
||||||
|
console.log("Captcha failed", captchaRes);
|
||||||
|
throw new Error("Captcha failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ ENABLE_AUTO_REPLY = false
|
|||||||
# COPYRIGHT = "Dream Hunter"
|
# COPYRIGHT = "Dream Hunter"
|
||||||
# default send balance, if not set, it will be 0
|
# default send balance, if not set, it will be 0
|
||||||
# DEFAULT_SEND_BALANCE = 1
|
# DEFAULT_SEND_BALANCE = 1
|
||||||
|
# Turnstile verification
|
||||||
|
# CF_TURNSTILE_SITE_KEY = ""
|
||||||
|
# CF_TURNSTILE_SECRET_KEY = ""
|
||||||
# dkim config
|
# dkim config
|
||||||
# DKIM_SELECTOR = ""
|
# DKIM_SELECTOR = ""
|
||||||
# DKIM_PRIVATE_KEY = ""
|
# DKIM_PRIVATE_KEY = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user