feat: add UserLogin (#209)

This commit is contained in:
Dream Hunter
2024-05-08 23:14:44 +08:00
committed by GitHub
parent 55b2603913
commit 1fa56dfe98
57 changed files with 2300 additions and 285 deletions

View File

@@ -1,9 +1,30 @@
# CHANGE LOG
## main branch
### DB Changes
新增 user 相关表,用于存储用户信息
- `db/2024-05-08-patch.sql`
### config changs
```toml
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
```
### function changs
- 增加用户注册功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证
## v0.3.3
- 修复 Admin 删除邮件报错
- UI: 回复邮件按钮, 引用原始邮件文本 #186
- UI: 回复邮件按钮, 引用原始邮件文本 #186
- 添加发送邮件地址黑名单
- 添加 `CF Turnstile` 人机验证配置
- 添加 `/external/api/send_mail` 发送邮件 api, 使用 body 验证 #194

View File

@@ -45,6 +45,7 @@
- [x] 支持 `DKIM`
- [x] `admin` 后台创建无前缀邮箱
- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
## Reference

21
db/2024-05-08-patch.sql Normal file
View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
user_info TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
CREATE TABLE IF NOT EXISTS users_address (
id INTEGER PRIMARY KEY,
user_id INTEGER,
address_id INTEGER UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);

View File

@@ -77,3 +77,25 @@ CREATE TABLE IF NOT EXISTS settings (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
user_info TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
CREATE TABLE IF NOT EXISTS users_address (
id INTEGER PRIMARY KEY,
user_id INTEGER,
address_id INTEGER UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);

View File

@@ -2,8 +2,11 @@ import { useGlobalState } from '../store'
import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const { loading, auth, jwt, settings, openSettings } = useGlobalState();
const { showAuth, adminAuth, showAdminAuth } = useGlobalState();
const {
loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings,
showAuth, adminAuth, showAdminAuth, userJwt
} = useGlobalState();
const instance = axios.create({
baseURL: API_BASE,
@@ -17,6 +20,7 @@ const apiFetch = async (path, options = {}) => {
method: options.method || 'GET',
data: options.body || null,
headers: {
'x-user-token': userJwt.value,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`,
@@ -25,7 +29,7 @@ const apiFetch = async (path, options = {}) => {
});
if (response.status === 401 && openSettings.value.auth) {
showAuth.value = true;
throw new Error("Unauthorized, you password is wrong")
throw new Error("Unauthorized, you access password is wrong")
}
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
@@ -90,10 +94,32 @@ const getSettings = async () => {
}
}
const adminShowPassword = async (id) => {
const getUserOpenSettings = async (message) => {
try {
const { password } = await apiFetch(`/admin/show_password/${id}`);
return password;
const res = await api.fetch(`/user_api/open_settings`);
Object.assign(userOpenSettings.value, res);
} catch (error) {
message.error(error.message || "fetch settings failed");
}
}
const getUserSettings = async (message) => {
try {
if (!userJwt.value) return;
const res = await api.fetch("/user_api/settings")
Object.assign(userSettings.value, res)
} catch (error) {
message.error(error.message || "error");
} finally {
userSettings.value.fetched = true;
}
}
const adminShowAddressCredential = async (id) => {
try {
const { jwt: addressCredential } = await apiFetch(`/admin/show_password/${id}`);
return addressCredential;
} catch (error) {
throw error;
}
@@ -109,10 +135,24 @@ const adminDeleteAddress = async (id) => {
}
}
const bindUserAddress = async () => {
if (!userJwt.value) return;
try {
await apiFetch(`/user_api/bind_address`, {
method: 'POST',
});
} catch (error) {
throw error;
}
}
export const api = {
fetch: apiFetch,
getSettings: getSettings,
getOpenSettings: getOpenSettings,
adminShowPassword: adminShowPassword,
adminDeleteAddress: adminDeleteAddress,
getSettings,
getOpenSettings,
getUserOpenSettings,
getUserSettings,
adminShowAddressCredential,
adminDeleteAddress,
bindUserAddress,
}

View File

@@ -41,7 +41,8 @@ const props = defineProps({
})
const {
localeCache, isDark, mailboxSplitSize, useIframeShowMail, sendMailModel
localeCache, isDark, mailboxSplitSize, indexTab,
useIframeShowMail, sendMailModel, preferShowTextMail
} = useGlobalState()
const autoRefresh = ref(false)
const autoRefreshInterval = ref(30)
@@ -55,6 +56,7 @@ const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const curMail = ref(null);
const showTextMail = ref(preferShowTextMail.value)
const { t } = useI18n({
locale: localeCache.value || 'zh',
@@ -69,7 +71,9 @@ const { t } = useI18n({
pleaseSelectMail: "Please select a mail to view.",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete this mail?',
reply: 'Reply'
reply: 'Reply',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show Html Mail'
},
zh: {
success: '成功',
@@ -81,7 +85,9 @@ const { t } = useI18n({
pleaseSelectMail: "请选择一封邮件查看。",
delete: '删除',
deleteMailTip: '确定要删除这封邮件吗?',
reply: '回复'
reply: '回复',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件'
}
}
});
@@ -173,7 +179,7 @@ const replyMail = async () => {
contentType: 'rich',
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
});
await router.push('/send');
indexTab.value = 'sendmail';
};
const onSpiltSizeChange = (size) => {
@@ -206,7 +212,7 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
<n-button @click="refresh" size="small" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
</div>
@@ -272,8 +278,12 @@ onBeforeUnmount(() => {
</template>
{{ t('reply') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
</n-space>
<iframe v-if="useIframeShowMail" :srcdoc="curMail.message"
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
@@ -420,4 +430,9 @@ onBeforeUnmount(() => {
.mail-item {
height: 100%;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@@ -1,7 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import UserLogin from '../views/user/UserLogin.vue'
import User from '../views/User.vue'
import SendMail from '../views/send/SendMail.vue'
import SendMail from '../views/index/SendMail.vue'
import Admin from '../views/Admin.vue'
const router = createRouter({
@@ -15,10 +16,6 @@ const router = createRouter({
path: '/user',
component: User
},
{
path: '/send',
component: SendMail
},
{
path: '/admin',
component: Admin

View File

@@ -39,7 +39,7 @@ export const useGlobalState = createGlobalState(
content: "",
});
const showAuth = ref(false);
const showPassword = ref(false);
const showAddressCredential = ref(false);
const showAdminAuth = ref(false);
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
@@ -50,6 +50,22 @@ export const useGlobalState = createGlobalState(
const adminSendBoxTabAddress = ref("");
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
const useIframeShowMail = useStorage('useIframeShowMail', false);
const preferShowTextMail = useStorage('preferShowTextMail', false);
const userJwt = useStorage('userJwt', '');
const userTab = useStorage('userTab', 'user_settings');
const indexTab = useStorage('indexTab', 'mailbox');
const userOpenSettings = ref({
enable: false,
enableMailVerify: false,
});
const userSettings = ref({
/** @type {boolean} */
fetched: false,
/** @type {string} */
user_email: '',
/** @type {number} */
user_id: 0,
});
return {
isDark,
toggleDark,
@@ -58,7 +74,7 @@ export const useGlobalState = createGlobalState(
sendMailModel,
openSettings,
showAuth,
showPassword,
showAddressCredential,
auth,
jwt,
localeCache,
@@ -69,6 +85,12 @@ export const useGlobalState = createGlobalState(
adminSendBoxTabAddress,
mailboxSplitSize,
useIframeShowMail,
preferShowTextMail,
userJwt,
userTab,
indexTab,
userOpenSettings,
userSettings,
}
},
)

View File

@@ -0,0 +1,6 @@
export const hashPassword = async (password) => {
// user crypto to hash password
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
const hashArray = Array.from(new Uint8Array(digest));
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
}

View File

@@ -10,6 +10,8 @@ import SendBox from './admin/SendBox.vue';
import Account from './admin/Account.vue';
import CreateAccount from './admin/CreateAccount.vue';
import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import Maintenance from './admin/Maintenance.vue';
@@ -37,6 +39,8 @@ const { t } = useI18n({
account: 'Account',
account_create: 'Create Account',
account_settings: 'Account Settings',
user_management: 'User Management',
user_settings: 'User Settings',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
@@ -50,6 +54,8 @@ const { t } = useI18n({
account: '账号',
account_create: '创建账号',
account_settings: '账号设置',
user_management: '用户管理',
user_settings: '用户设置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
@@ -90,6 +96,12 @@ onMounted(async () => {
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="user_management" :tab="t('user_management')">
<UserManagement />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettings />
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>

View File

@@ -1,32 +1,32 @@
<script setup>
import useClipboard from 'vue-clipboard3'
import { ref, h, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import {
DarkModeFilled, LightModeFilled, MenuFilled,
AdminPanelSettingsFilled, SendFilled
AdminPanelSettingsFilled
} from '@vicons/material'
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
import Login from './Login.vue'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
const { toClipboard } = useClipboard()
const message = useMessage()
const {
jwt, localeCache, toggleDark, isDark, settings, showPassword,
localeCache, toggleDark, isDark, openSettings,
showAuth, adminAuth, auth, loading
} = useGlobalState()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const isAdminRoute = computed(() => route.path.includes('admin'))
const showMobileMenu = ref(false)
const menuValue = computed(() => {
if (route.path.includes("user")) return "user";
if (route.path.includes("admin")) return "admin";
return "home";
});
const authFunc = async () => {
try {
@@ -49,19 +49,11 @@ const { t } = useI18n({
dark: 'Dark',
light: 'Light',
accessHeader: 'Access Password',
accessTip: 'Please enter the correct password',
accessTip: 'Please enter the correct access password',
home: 'Home',
menu: 'Menu',
user: 'User',
sendMail: 'Send Mail',
yourAddress: 'Your email address is',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
fetchAddressError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
},
zh: {
title: 'Cloudflare 临时邮件',
@@ -72,36 +64,25 @@ const { t } = useI18n({
home: '主页',
menu: '菜单',
user: '用户',
sendMail: '发送邮件',
yourAddress: '你的邮箱地址是',
ok: '确定',
copy: '复制',
copied: '已复制',
fetchAddressError: '登录密码无效或账号不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
}
}
});
const showUserMenu = computed(() => !!settings.value.address)
const menuOptions = computed(() => [
{
label: () => h(
NButton,
label: () => h(NButton,
{
text: true,
size: "small",
type: menuValue.value == "home" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push('/'); showMobileMenu.value = false; }
onClick: async () => { await router.push('/'); showMobileMenu.value = false; }
},
{
default: () => t('home'),
icon: () => h(NIcon, { component: Home })
}
),
}),
key: "home"
},
{
@@ -110,8 +91,26 @@ const menuOptions = computed(() => [
{
text: true,
size: "small",
type: menuValue.value == "user" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push('/admin'); showMobileMenu.value = false; }
onClick: async () => { await router.push("/user"); showMobileMenu.value = false; }
},
{
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
}
),
key: "user",
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
type: menuValue.value == "admin" ? "primary" : "default",
style: "width: 100%",
onClick: async () => { await router.push('/admin'); showMobileMenu.value = false; }
},
{
default: () => "Admin",
@@ -121,23 +120,6 @@ const menuOptions = computed(() => [
show: !!adminAuth.value,
key: "admin"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
onClick: () => { router.push("/user"); showMobileMenu.value = false; }
},
{
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
}
),
show: showUserMenu.value,
key: "user",
},
{
label: () => h(
NButton,
@@ -197,18 +179,8 @@ const menuOptions = computed(() => [
}
]);
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
}
onMounted(async () => {
await api.getOpenSettings(message);
await api.getSettings();
});
</script>
@@ -223,7 +195,7 @@ onMounted(async () => {
</template>
<template #extra>
<n-space>
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" />
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" responsive />
<n-button v-else :text="true" @click="showMobileMenu = !showMobileMenu" style="margin-right: 10px;">
<template #icon>
<n-icon :component="MenuFilled" />
@@ -238,49 +210,6 @@ onMounted(async () => {
<n-menu :options="menuOptions" />
</n-drawer-content>
</n-drawer>
<div v-if="!isAdminRoute">
<n-card v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="settings.address">
<n-alert v-if="settings.has_v1_mails" type="warning" show-icon closable>
<span>
<n-button tag="a" target="_blank" tertiary type="info" size="small"
href="https://mail-v1.awsl.uk">
<b>{{ t('mailV1Alert') }} </b>
</n-button>
</span>
</n-alert>
<n-alert type="info" show-icon>
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="router.push('/send')" size="small" tertiary
type="primary">
<n-icon :component="SendFilled" /> {{ t('sendMail') }}
</n-button>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
</n-alert>
</div>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" show-icon>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<Login />
</n-card>
</div>
</div>
<n-modal v-model:show="showPassword" preset="dialog" :title="t('password')">
<span>
<p>{{ t("passwordTip") }}</p>
</span>
<n-card>
<b>{{ jwt }}</b>
</n-card>
</n-modal>
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>

View File

@@ -1,9 +1,37 @@
<script setup>
import MailBox from '../components/MailBox.vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { api } from '../api'
const { settings, openSettings } = useGlobalState()
import AddressBar from './index/AddressBar.vue';
import MailBox from '../components/MailBox.vue';
import AutoReply from './index/AutoReply.vue';
import SendBox from './index/SendBox.vue';
import SendMail from './index/SendMail.vue';
import AccountSettings from './index/AccountSettings.vue';
const { localeCache, settings, openSettings, indexTab } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mailbox: 'Mail Box',
sendbox: 'Send Box',
sendmail: 'Send Mail',
auto_reply: 'Auto Reply',
accountSettings: 'Account Settings',
},
zh: {
mailbox: '收件箱',
sendbox: '发件箱',
sendmail: '发送邮件',
auto_reply: '自动回复',
accountSettings: '账户设置',
}
}
});
const fetchMailData = async (limit, offset) => {
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
@@ -15,8 +43,25 @@ const deleteMail = async (curMailId) => {
</script>
<template>
<div v-if="settings.address">
<MailBox :showEMailTo="false" :showReply="true" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
<div>
<AddressBar />
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab">
<n-tab-pane name="mailbox" :tab="t('mailbox')">
<MailBox :showEMailTo="false" :showReply="true" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
</n-tab-pane>
<n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox />
</n-tab-pane>
<n-tab-pane name="sendmail" :tab="t('sendmail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -1,28 +1,29 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useStorage } from '@vueuse/core'
import { useGlobalState } from '../store'
import AutoReply from './user/AutoReply.vue';
import SendBox from './send/SendBox.vue';
import Account from './user/Account.vue';
import AddressMangement from './user/AddressManagement.vue';
import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
const { localeCache, settings, openSettings } = useGlobalState()
const userTab = useStorage('userTab', 'account')
const {
localeCache, userTab, userOpenSettings, userSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
sendbox: 'Send Box',
auto_reply: 'Auto Reply',
account: 'Account',
address_management: 'Address Management',
user_settings: 'User Settings',
bind_address: 'Bind Mail Address',
},
zh: {
sendbox: '发件箱',
auto_reply: '自动回复',
account: '账户',
address_management: '地址管理',
user_settings: '用户设置',
bind_address: '绑定邮箱地址',
}
}
});
@@ -30,16 +31,17 @@ const { t } = useI18n({
</script>
<template>
<div v-if="settings.address">
<n-tabs type="card" v-model:value="userTab">
<n-tab-pane name="account" :tab="t('account')">
<Account />
<div>
<UserBar />
<n-tabs v-if="userSettings.user_email" type="card" v-model:value="userTab">
<n-tab-pane name="address_management" :tab="t('address_management')">
<AddressMangement />
</n-tab-pane>
<n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox />
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
<n-tab-pane name="bind_address" :tab="t('bind_address')">
<BindAddress />
</n-tab-pane>
</n-tabs>
</div>

View File

@@ -23,9 +23,9 @@ const { t } = useI18n({
updated_at: 'Update At',
mail_count: 'Mail Count',
send_count: 'Send Count',
showPass: 'Show Passwrod',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
showCredential: 'Show Mail Address Credential',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
delteAccount: 'Delete Account',
@@ -42,9 +42,9 @@ const { t } = useI18n({
updated_at: '更新时间',
mail_count: '邮件数量',
send_count: '发送数量',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
showCredential: '查看邮箱地址凭证',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
delteAccount: '删除邮箱',
@@ -58,8 +58,8 @@ const { t } = useI18n({
}
});
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const showEmailCredential = ref(false)
const curEmailCredential = ref("")
const curDeleteAddressId = ref(0);
const addressQuery = ref("")
@@ -68,16 +68,16 @@ const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showDelteAccount = ref(false)
const showDeleteAccount = ref(false)
const showPassword = async (id) => {
const showCredential = async (id) => {
try {
curEmailPassword.value = await api.adminShowPassword(id)
showEmailPassword.value = true
curEmailCredential.value = await api.adminShowAddressCredential(id)
showEmailCredential.value = true
} catch (error) {
message.error(error.message || "error");
showEmailPassword.value = false
curEmailPassword.value = ""
showEmailCredential.value = false
curEmailCredential.value = ""
}
}
@@ -89,7 +89,7 @@ const deleteEmail = async () => {
} catch (error) {
message.error(error.message || "error");
} finally {
showDelteAccount.value = false
showDeleteAccount.value = false
}
}
@@ -197,9 +197,9 @@ const columns = [
label: () => h(NButton,
{
text: true,
onClick: () => showPassword(row.id)
onClick: () => showCredential(row.id)
},
{ default: () => t('showPass') }
{ default: () => t('showCredential') }
),
},
{
@@ -232,7 +232,7 @@ const columns = [
text: true,
onClick: () => {
curDeleteAddressId.value = row.id;
showDelteAccount.value = true;
showDeleteAccount.value = true;
}
},
{ default: () => t('delete') }
@@ -262,20 +262,20 @@ onMounted(async () => {
<template>
<div>
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
<div>{{ t("addressCredential") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<b>{{ curEmailPassword }}</b>
<b>{{ curEmailCredential }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
@@ -285,7 +285,7 @@ onMounted(async () => {
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-button @click="fetchData" type="primary" ghost>
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>

View File

@@ -17,7 +17,7 @@ const { t } = useI18n({
</script>
<template>
<n-alert v-if="openSettings.adminContact" type="info" show-icon>
<n-alert v-if="openSettings.adminContact" :show-icon="false">
<span>{{ t('adminContact', { msg: openSettings.adminContact }) }}</span>
</n-alert>
</template>

View File

@@ -19,7 +19,7 @@ const { t } = useI18n({
creatNewEmail: 'Get New Email',
fillInAllFields: 'Please fill in all fields',
successTip: 'Success Created',
password: 'Password',
addressCredential: 'Mail Address Credential',
},
zh: {
address: '地址',
@@ -27,7 +27,7 @@ const { t } = useI18n({
creatNewEmail: '创建新邮箱',
fillInAllFields: '请填写完整信息',
successTip: '创建成功',
password: '密码',
addressCredential: '邮箱地址凭证',
}
}
});
@@ -70,8 +70,8 @@ onMounted(async () => {
<template>
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('password')">
<p>{{ t('password') }}</p>
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<p>{{ t('addressCredential') }}</p>
<n-card>
<b>{{ result }}</b>
</n-card>

View File

@@ -52,7 +52,7 @@ onMounted(async () => {
<div>
<n-input-group>
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
<n-button @click="queryAddress" type="primary" ghost>
<n-button @click="queryAddress" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>

View File

@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, adminAuth, adminSendBoxTabAddress } = useGlobalState()
const { localeCache, adminAuth, adminSendBoxTabAddress, showAdminAuth } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
@@ -102,7 +102,7 @@ const columns = [
h(NButton,
{
type: 'success',
ghost: true,
tertiary: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
@@ -135,7 +135,7 @@ onMounted(async () => {
</n-modal>
<n-input-group>
<n-input v-model:value="adminSendBoxTabAddress" />
<n-button @click="fetchData" type="primary" ghost>
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>

View File

@@ -14,6 +14,7 @@ const { t } = useI18n({
en: {
address: 'Address',
success: 'Success',
is_enabled: 'Is Enabled',
enable: 'Enable',
disable: 'Disable',
modify: 'Modify',
@@ -28,6 +29,7 @@ const { t } = useI18n({
zh: {
address: '地址',
success: '成功',
is_enabled: '是否启用',
enable: '启用',
disable: '禁用',
modify: '修改',
@@ -108,7 +110,7 @@ const columns = [
key: "balance"
},
{
title: "Enabled",
title: t('is_enabled'),
key: "enabled",
render(row) {
return h('div', [
@@ -124,7 +126,7 @@ const columns = [
h(NButton,
{
type: 'success',
ghost: true,
tertiary: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
@@ -170,7 +172,7 @@ onMounted(async () => {
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" />
<n-button @click="fetchData" type="primary" ghost>
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>

View File

@@ -0,0 +1,289 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { NMenu, NButton, NBadge } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { hashPassword } from '../../utils';
const { localeCache, loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
user_email: 'User Email',
address_count: 'Address Count',
created_at: 'Created At',
actions: 'Actions',
query: 'Query',
itemCount: 'itemCount',
deleteUser: 'Delete User',
delete: 'Delete',
deleteUserTip: 'Are you sure you want to delete this user?',
resetPassword: 'Reset Password',
pleaseInput: 'Please input complete information',
createUser: 'Create User',
email: 'Email',
password: 'Password',
},
zh: {
success: '成功',
user_email: '用户邮箱',
address_count: '地址数量',
created_at: '创建时间',
actions: '操作',
query: '查询',
itemCount: '总数',
deleteUser: '删除用户',
delete: '删除',
deleteUserTip: '确定要删除此用户吗?',
resetPassword: '重置密码',
pleaseInput: '请输入完整信息',
createUser: '创建用户',
email: '邮箱',
password: '密码',
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const userQuery = ref('')
const showResetPassword = ref(false)
const newResetPassword = ref('')
const showDeleteUser = ref(false)
const curUserId = ref(0)
const showCreateUser = ref(false)
const user = ref({
email: "",
password: ""
})
const fetchData = async () => {
try {
const { results, count: userCount } = await api.fetch(
`/admin/users`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (userQuery.value ? `&query=${userQuery.value}` : '')
);
data.value = results;
if (userCount > 0) {
count.value = userCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const resetPassword = async () => {
if (!newResetPassword.value) {
message.error(t('pleaseInput'));
return;
}
try {
await api.fetch(`/admin/users/${curUserId.value}/reset_password`, {
method: "POST",
body: JSON.stringify({
password: await hashPassword(newResetPassword.value)
})
});
message.success(t('success'));
showResetPassword.value = false;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const createUser = async () => {
if (!user.value.email || !user.value.password) {
message.error(t('pleaseInput'));
return;
}
try {
await api.fetch(`/admin/users`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
password: await hashPassword(user.value.password)
})
});
message.success(t('success'));
await fetchData();
showCreateUser.value = false;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const deleteUser = async () => {
try {
await api.fetch(`/admin/users/${curUserId.value}`, {
method: "DELETE"
});
message.success(t('success'));
showDeleteUser.value = false;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('user_email'),
key: "user_email"
},
{
title: t('address_count'),
key: "address_count",
render(row) {
return h(NBadge, {
value: row.address_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
h(NMenu, {
mode: "horizontal",
options: [
{
label: t('actions'),
icon: () => h(MenuFilled),
key: "action",
children: [
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curUserId.value = row.id;
newResetPassword.value = '';
showResetPassword.value = true;
}
},
{ default: () => t('resetPassword') }
),
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curUserId.value = row.id;
user.value.email = '';
user.value.password = '';
showDeleteUser.value = true;
}
},
{ default: () => t('delete') }
)
}
]
}
]
})
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showCreateUser" preset="dialog" :title="t('createUser')">
<n-form>
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
</n-form>
<template #action>
<n-button :loading="loading" @click="createUser" size="small" tertiary type="primary">
{{ t('createUser') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="newResetPassword" type="password" show-password-on="click" />
</n-form-item-row>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="primary">
{{ t('resetPassword') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDeleteUser" preset="dialog" :title="t('deleteUser')">
<p>{{ t('deleteUserTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteUser" size="small" tertiary type="error">
{{ t('deleteUser') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="userQuery" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
<template #suffix>
<n-button @click="showCreateUser = true" size="small" tertiary type="primary"
style="margin-left: 10px">
{{ t('createUser') }}
</n-button>
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,130 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
save: 'Save',
successTip: 'Save Success',
enable: 'Enable',
enableUserRegister: 'Allow User Register',
enableMailVerify: 'Enable Mail Verify (Send address must be an address in the system with a balance and can send mail normally)',
verifyMailSender: 'Verify Mail Sender',
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
mailAllowList: 'Mail Address Allow List',
maxAddressCount: 'Maximum number of email addresses that can be binded',
},
zh: {
save: '保存',
successTip: '保存成功',
enable: '启用',
enableUserRegister: "允许用户注册",
enableMailVerify: '启用邮件验证(发送地址必须是系统中能有余额且能正常发送邮件的地址)',
verifyMailSender: '验证邮件发送地址',
enableMailAllowList: '启用邮件地址白名单(可手动输入)',
mailAllowList: '邮件地址白名单',
maxAddressCount: '可绑定最大邮箱地址数量',
}
}
});
const commonMail = [
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
"icloud.com", "yahoo.com", "foxmail.com"
]
const mailAllowOptions = commonMail.map((item) => {
return { label: item, value: item }
})
const userSettings = ref({
enable: false,
enableMailVerify: false,
verifyMailSender: "",
enableMailAllowList: false,
mailAllowList: commonMail,
maxAddressCount: 5,
});
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/user_settings`)
Object.assign(userSettings.value, res)
} catch (error) {
message.error(error.message || "error");
}
}
const save = async () => {
try {
await api.fetch(`/admin/user_settings`, {
method: 'POST',
body: JSON.stringify(userSettings.value)
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card style="max-width: 600px;">
<n-form :model="userSettings">
<n-form-item-row :label="t('enableUserRegister')">
<n-checkbox v-model:checked="userSettings.enable" />
</n-form-item-row>
<n-form-item-row :label="t('enableMailVerify')">
<n-input-group>
<n-checkbox v-model:checked="userSettings.enableMailVerify" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-input v-model:value="userSettings.verifyMailSender" style="width: 80%;"
:placeholder="t('verifyMailSender')" />
</n-input-group>
</n-form-item-row>
<n-form-item-row :label="t('enableMailAllowList')">
<n-input-group>
<n-checkbox v-model:checked="userSettings.enableMailAllowList" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="userSettings.mailAllowList" filterable multiple tag style="width: 80%;"
:options="mailAllowOptions" :placeholder="t('mailAllowList')" />
</n-input-group>
</n-form-item-row>
<n-form-item-row :label="t('maxAddressCount')">
<n-input-group>
<n-input-number v-model:value="userSettings.maxAddressCount"
:placeholder="t('maxAddressCount')" />
</n-input-group>
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-form>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -7,8 +7,8 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
const {
jwt, localeCache, settings, showPassword, loading,
mailboxSplitSize, useIframeShowMail
jwt, localeCache, settings, showAddressCredential, loading,
mailboxSplitSize, useIframeShowMail, preferShowTextMail
} = useGlobalState()
const router = useRouter()
const message = useMessage()
@@ -20,20 +20,22 @@ const { t } = useI18n({
messages: {
en: {
mailboxSplitSize: 'Mailbox Split Size',
useIframeShowMail: 'Use iframe Show Mail',
useIframeShowMail: 'Use iframe Show HTML Mail',
preferShowTextMail: 'Display text Mail by default',
logout: "Logout",
delteAccount: "Delete Account",
showPassword: 'Show Password',
showAddressCredential: 'Show Address Credential',
logoutConfirm: 'Are you sure to logout?',
delteAccount: "Delete Account",
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
},
zh: {
mailboxSplitSize: '邮箱界面分栏大小',
useIframeShowMail: '使用iframe显示邮件',
preferShowTextMail: '默认以文本显示邮件',
useIframeShowMail: '使用iframe显示HTML邮件',
logout: '退出登录',
delteAccount: "删除账户",
showPassword: '查看密码',
showAddressCredential: '查看邮箱地址凭证',
logoutConfirm: '确定要退出登录吗?',
delteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
@@ -72,12 +74,15 @@ const deleteAccount = async () => {
0.75: '0.75'
}" />
</n-form-item-row>
<n-form-item-row :label="t('preferShowTextMail')">
<n-switch v-model:value="preferShowTextMail" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('useIframeShowMail')">
<n-switch v-model:value="useIframeShowMail" :round="false" />
</n-form-item-row>
</n-card>
<n-button @click="showPassword = true" type="primary" secondary block strong>
{{ t('showPassword') }}
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}

View File

@@ -0,0 +1,128 @@
<script setup>
import useClipboard from 'vue-clipboard3'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Copy, User } from '@vicons/fa'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Login from './Login.vue'
const { toClipboard } = useClipboard()
const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, settings, showAddressCredential, openSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
yourAddress: 'Your email address is',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
fetchAddressError: 'Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
userLogin: 'User Login',
},
zh: {
yourAddress: '你的邮箱地址是',
ok: '确定',
copy: '复制',
copied: '已复制',
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
userLogin: '用户登录',
}
}
});
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
}
onMounted(async () => {
await api.getSettings();
});
</script>
<template>
<div>
<n-card v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="settings.address">
<n-alert v-if="settings.has_v1_mails" type="warning" :show-icon="false" closable>
<span>
<n-button tag="a" target="_blank" tertiary type="info" size="small" href="https://mail-v1.awsl.uk">
<b>{{ t('mailV1Alert') }} </b>
</n-button>
</span>
</n-alert>
<n-alert type="info" :show-icon="false">
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
</n-alert>
</div>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" :show-icon="false" closable>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<Login />
<n-divider />
<n-button @click="router.push('/user')" type="primary" block secondary strong>
<template #icon>
<n-icon :component="User" />
</template>
{{ t('userLogin') }}
</n-button>
</n-card>
</div>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<b>{{ jwt }}</b>
</n-card>
</n-modal>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.n-card {
margin-top: 10px;
}
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -2,33 +2,41 @@
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import AdminContact from './admin/AdminContact.vue'
import Turnstile from '../components/Turnstile.vue'
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
import { useGlobalState } from '../store'
import { api } from '../api'
import AdminContact from '../admin/AdminContact.vue'
import Turnstile from '../../components/Turnstile.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, loading, openSettings, showPassword
jwt, localeCache, loading, openSettings,
showAddressCredential, userSettings
} = useGlobalState()
const tabValue = ref('signin')
const password = ref('')
const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const login = async () => {
if (!password.value) {
message.error(t('passwordInput'));
if (!credential.value) {
message.error(t('credentialInput'));
return;
}
try {
jwt.value = password.value;
await api.getSettings()
location.reload()
jwt.value = credential.value;
await api.getSettings();
try {
await api.bindUserAddress();
} catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`);
}
await router.push("/");
} catch (error) {
message.error(error.message || "error");
}
@@ -40,28 +48,32 @@ const { t } = useI18n({
en: {
login: 'Login',
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
getNewEmail: 'Get New Email',
getNewEmail: 'Create New Email',
getNewEmailTip1: 'Please input the email you want to use. only allow ., a-z, A-Z and 0-9',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
password: 'Password',
credential: 'Email Address Credential',
ok: 'OK',
generateName: 'Generate Fake Name',
help: 'Help',
passwordInput: 'Please input the password',
credentialInput: 'Please input the Mail Address Credential',
bindUserInfo: 'Logged in user, login without binding email or create new email address will bind to current user',
bindUserAddressError: 'Error when bind email address to user',
},
zh: {
login: '登录',
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '注册新邮箱',
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '创建新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
password: '密码',
credential: '邮箱地址凭据',
ok: '确定',
generateName: '生成随机名字',
help: '帮助',
passwordInput: '请输入密码',
credentialInput: '请输入邮箱地址凭据',
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
bindUserAddressError: '绑定邮箱地址到用户时错误',
}
}
});
@@ -95,7 +107,13 @@ const newEmail = async () => {
});
jwt.value = res["jwt"];
await api.getSettings();
showPassword.value = true;
await router.push("/");
showAddressCredential.value = true;
try {
await api.bindUserAddress();
} catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`);
}
} catch (error) {
message.error(error.message || "error");
}
@@ -108,17 +126,26 @@ onMounted(async () => {
<template>
<div>
<n-alert v-if="userSettings.user_email" :show-icon="false" closable>
<span>{{ t('bindUserInfo') }}</span>
</n-alert>
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="t('login')">
<n-form>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="password" type="textarea" :autosize="{ minRows: 3 }" />
<n-form-item-row :label="t('credential')" required>
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
<template #icon>
<n-icon :component="EmailOutlined" />
</template>
{{ t('login') }}
</n-button>
<n-button v-if="openSettings.enableUserCreateEmail" @click="tabValue = 'register'" block secondary
strong>
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>
{{ t('getNewEmail') }}
</n-button>
</n-form>
@@ -145,13 +172,16 @@ onMounted(async () => {
</n-input-group>
<Turnstile v-model:value="cfToken" />
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
{{ t('ok') }}
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>
{{ t('getNewEmail') }}
</n-button>
</n-form>
</n-spin>
</n-tab-pane>
<n-tab-pane name="help" :tab="t('help')">
<n-alert type="info" show-icon>
<n-alert :show-icon="false">
<span>{{ t('pleaseGetNewEmail') }}</span>
</n-alert>
<AdminContact />
@@ -171,4 +201,8 @@ onMounted(async () => {
.n-form .n-button {
margin-top: 10px;
}
.n-form {
text-align: left;
}
</style>

View File

@@ -138,7 +138,7 @@ onMounted(async () => {
{{ t('itemCount') }}: {{ itemCount }}
</template>
<template #suffix>
<n-button @click="fetchData" type="primary" size="small" ghost>
<n-button @click="fetchData" type="primary" size="small" tertiary>
{{ t('refresh') }}
</n-button>
</template>

View File

@@ -7,14 +7,13 @@ import AdminContact from '../admin/AdminContact.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import router from '../../router'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const { settings, sendMailModel } = useGlobalState()
const { settings, sendMailModel, indexTab } = useGlobalState()
const { t } = useI18n({
locale: 'zh',
@@ -91,7 +90,7 @@ const send = async () => {
message.error(error.message || "error");
} finally {
message.success(t("successSend"));
router.push('/user');
indexTab.value = 'sendbox'
}
}
@@ -145,15 +144,16 @@ onMounted(async () => {
<div class="center" v-if="settings.address">
<n-card>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<n-alert type="warning" show-icon>
<n-alert type="warning" :show-icon="false">
{{ t('requestAccessTip') }}
<n-button type="primary" ghost @click="requestAccess">{{ t('requestAccess') }}</n-button>
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
}}</n-button>
</n-alert>
<br />
<AdminContact />
</div>
<div v-else>
<n-alert type="info" show-icon>
<n-alert type="info" :show-icon="false">
{{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert>
<div class="right">

View File

@@ -0,0 +1,170 @@
<script setup>
import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router';
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, jwt } = useGlobalState()
const message = useMessage()
const router = useRouter()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'success',
name: 'Name',
mail_count: 'Mail Count',
send_count: 'Send Count',
actions: 'Actions',
changeMailAddress: 'Change Mail Address',
unbindAddress: 'Unbind Address',
unbindAddressTip: 'Before unbinding, please switch to this email address and save the email address credential.',
},
zh: {
success: '成功',
name: '名称',
mail_count: '邮件数量',
send_count: '发送数量',
actions: '操作',
changeMailAddress: '切换邮箱地址',
unbindAddress: '解绑地址',
unbindAddressTip: '解绑前请切换到此邮箱地址并保存邮箱地址凭证。',
}
}
});
const data = ref([])
const changeMailAddress = async (address_id) => {
try {
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
message.success(t('changeMailAddress') + " " + t('success'));
if (!res.jwt) {
message.error("jwt not found");
return;
}
jwt.value = res.jwt;
await router.push('/');
location.reload();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const unbindAddress = async (address_id) => {
try {
const res = await api.fetch(`/user_api/unbind_address`, {
method: 'POST',
body: JSON.stringify({ address_id })
});
message.success(t('unbindAddress') + " " + t('success'));
await fetchData();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/user_api/bind_address`
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('name'),
key: "name"
},
{
title: t('mail_count'),
key: "mail_count",
render(row) {
return h(NBadge, {
value: row.mail_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('send_count'),
key: "send_count",
render(row) {
return h(NBadge, {
value: row.send_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
h(NPopconfirm,
{
onPositiveClick: () => changeMailAddress(row.id)
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "primary",
},
{ default: () => t('changeMailAddress') }
),
default: () => `${t('changeMailAddress')}?`
}
),
h(NPopconfirm,
{
onPositiveClick: () => unbindAddress(row.id)
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "error",
},
{ default: () => t('unbindAddress') }
),
default: () => t('unbindAddressTip')
}
),
])
}
}
]
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import Login from '../index/Login.vue'
const { userJwt, localeCache, userSettings, } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
logout: 'Logout',
},
zh: {
logout: '退出登录',
}
}
});
const fetchData = async () => {
}
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div class="center" v-if="userSettings.user_email">
<n-card style="max-width: 600px;">
<Login />
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,76 @@
<script setup>
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import UserLogin from './UserLogin.vue'
const message = useMessage()
const router = useRouter()
const {
localeCache, userSettings, userJwt, userOpenSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
currentUser: 'Current Login User',
fetchUserSettingsError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
},
zh: {
currentUser: '当前登录用户',
fetchUserSettingsError: '登录信息已过期或账号不存在,也可能是网络连接异常,请稍后再尝试。',
}
}
});
onMounted(async () => {
await api.getUserOpenSettings(message);
await api.getUserSettings(message);
});
</script>
<template>
<div>
<n-card v-if="!userSettings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="userSettings.user_email">
<n-alert type="success" :show-icon="false">
<span>
<b>{{ t('currentUser') }} <b>{{ userSettings.user_email }}</b></b>
</span>
</n-alert>
</div>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="userJwt" type="warning" :show-icon="false" closable>
<span>{{ t('fetchUserSettingsError') }}</span>
</n-alert>
<UserLogin />
</n-card>
</div>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

View File

@@ -0,0 +1,248 @@
<script setup>
import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref } from "vue";
import { useI18n } from 'vue-i18n'
import { api } from '../../api';
import { useGlobalState } from '../../store'
import { hashPassword } from '../../utils';
import Turnstile from '../../components/Turnstile.vue';
const { userJwt, localeCache, userTab, userOpenSettings } = useGlobalState()
const message = useMessage();
const router = useRouter();
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
login: 'Login',
register: 'Register',
email: 'Email',
password: 'Password',
verifyCode: 'Verification Code',
verifyCodeSent: 'Verification Code Sent, expires in {timeout} seconds',
waitforVerifyCode: 'Wait for {timeout} seconds',
sendVerificationCode: 'Send Verification Code',
forgotPassword: 'Forgot Password',
cannotForgotPassword: 'Mail verification is disabled or register is disabled, cannot reset password, please contact administrator',
resetPassword: 'Reset Password',
pleaseInput: 'Please input email and password',
pleaseInputEmail: 'Please input email',
pleaseInputCode: 'Please input code',
pleaseCompleteTurnstile: 'Please complete turnstile',
pleaseLogin: 'Please login',
},
zh: {
login: '登录',
register: '注册',
email: '邮箱',
password: '密码',
verifyCode: '验证码',
sendVerificationCode: '发送验证码',
verifyCodeSent: '验证码已发送, {timeout} 秒后失效',
waitforVerifyCode: '等待{timeout}秒',
forgotPassword: '忘记密码',
cannotForgotPassword: '未开启邮箱验证或未开启注册功能,无法重置密码,请联系管理员',
resetPassword: '重置密码',
pleaseInput: '请输入邮箱和密码',
pleaseInputEmail: '请输入邮箱',
pleaseInputCode: '请输入验证码',
pleaseCompleteTurnstile: '请完成人机验证',
pleaseLogin: '请登录',
}
}
});
const tabValue = ref("signin");
const showModal = ref(false);
const user = ref({
email: "",
password: "",
code: ""
});
const cfToken = ref("")
const emailLogin = async () => {
if (!user.value.email || !user.value.password) {
message.error(t('pleaseInput'));
return;
}
try {
const res = await api.fetch(`/user_api/login`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
// hash password
password: await hashPassword(user.value.password)
})
});
userJwt.value = res.jwt;
location.reload();
} catch (error) {
message.error(error.message || "login failed");
}
};
const verifyCodeExpire = ref(0);
const verifyCodeTimeout = ref(0);
const getVerifyCodeTimeout = () => {
if (!verifyCodeExpire.value || verifyCodeExpire.value < new Date().getTime()) return 0;
return Math.round((verifyCodeExpire.value - new Date().getTime()) / 1000);
};
const sendVerificationCode = async () => {
if (!user.value.email) {
message.error(t('pleaseInputEmail'));
return;
}
if (!cfToken.value && userOpenSettings.value.enableMailVerify) {
message.error(t('pleaseCompleteTurnstile'));
return;
}
try {
const res = await api.fetch(`/user_api/verify_code`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
cf_token: cfToken.value
})
});
if (res && res.expirationTtl) {
message.success(t('verifyCodeSent', { timeout: res.expirationTtl }));
verifyCodeExpire.value = new Date().getTime() + res.expirationTtl * 1000;
const intervalId = setInterval(() => {
verifyCodeTimeout.value = getVerifyCodeTimeout();
if (verifyCodeTimeout.value <= 0) {
clearInterval(intervalId);
verifyCodeTimeout.value = 0;
}
}, 1000);
}
} catch (error) {
message.error(error.message || "send verification code failed");
}
};
const emailSignup = async () => {
if (!user.value.email || !user.value.password) {
message.error(t('pleaseInput'));
return;
}
if (!user.value.code && userOpenSettings.value.enableMailVerify) {
message.error(t('pleaseInputCode'));
return;
}
try {
const res = await api.fetch(`/user_api/register`, {
method: "POST",
body: JSON.stringify({
email: user.value.email,
// hash password
password: await hashPassword(user.value.password),
code: user.value.code
}),
message: message
});
if (res) {
tabValue.value = "signin";
message.success(t('pleaseLogin'));
}
showModal.value = false;
} catch (error) {
message.error(error.message || "register failed");
}
};
onMounted(async () => {
});
</script>
<template>
<div class="center">
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="t('login')">
<n-form>
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<n-button @click="emailLogin" type="primary" block secondary strong>
{{ t('login') }}
</n-button>
<n-button @click="showModal = true" type="info" quaternary size="tiny">
{{ t('forgotPassword') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">
<n-form>
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
:disabled="verifyCodeTimeout > 0">
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
: t('sendVerificationCode') }}
</n-button>
</n-input-group>
</n-form-item-row>
</n-form>
<n-button @click="emailSignup" type="primary" block secondary strong>
{{ t('register') }}
</n-button>
</n-tab-pane>
</n-tabs>
<n-modal v-model:show="showModal" style="max-width: 600px;" preset="card" :title="t('forgotPassword')">
<n-form v-if="userOpenSettings.enable && userOpenSettings.enableMailVerify">
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-model:value="cfToken" />
<n-form-item-row :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
:disabled="verifyCodeTimeout > 0">
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
: t('sendVerificationCode') }}
</n-button>
</n-input-group>
</n-form-item-row>
<n-button @click="emailSignup" type="primary" block secondary strong>
{{ t('resetPassword') }}
</n-button>
</n-form>
<n-alert v-else :show-icon="false">
<span>
{{ t('cannotForgotPassword') }}
</span>
</n-alert>
</n-modal>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { userJwt, localeCache, userSettings, } = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
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',
},
zh: {
logout: '退出登录',
logoutConfirm: '确定要退出登录吗?',
passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码',
}
}
});
const logout = async () => {
userJwt.value = '';
location.reload()
}
const fetchData = async () => {
}
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div class="center" v-if="userSettings.user_email">
<n-card>
<n-alert :show-icon="false">
<span>
{{ t('passordTip') }}
</span>
</n-alert>
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}
</n-button>
</n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="logout" size="small" tertiary type="primary">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
</div>
</template>
<style scoped>
.center {
display: flex;
justify-content: center;
}
.n-card {
max-width: 800px;
text-align: left;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -38,6 +38,8 @@ wrangler d1 execute dev --file=db/schema.sql
# 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
# create a namespace, and copy the output to wrangler.toml in the next step
wrangler kv:namespace create DEV
```
![d1](/readme_assets/d1.png)
@@ -99,6 +101,11 @@ binding = "DB"
database_name = "xxx" # D1 database name
database_id = "xxx" # D1 database ID
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
# Create a new address current limiting configuration
# [[unsafe.bindings]]
# name = "RATE_LIMITER"

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -5,9 +5,11 @@
## 初始化数据库
```bash
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
```
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
@@ -22,6 +24,7 @@ wrangler d1 execute dev --file=db/schema.sql
找到需要执行的 `patch` 文件, 执行, 例如:
```bash
wrangler d1 execute dev --file=db/2024-01-13-patch.sql
wrangler d1 execute dev --file=db/2024-04-03-patch.sql
cd worker
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql
```

View File

@@ -8,6 +8,17 @@ pnpm install
cp wrangler.toml.template wrangler.toml
```
## 创建 KV 缓存
> [!NOTE]
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
通过命令行创建 KV 缓存,或者在 Cloudflare 控制台创建,然后复制对应配置到 `wrangler.toml` 文件中
```bash
wrangler kv:namespace create DEV
```
## 修改 `wrangler.toml` 配置文件
```toml
@@ -58,6 +69,11 @@ binding = "DB"
database_name = "xxx" # D1 数据库名称
database_id = "xxx" # D1 数据库 ID
# kv config 用于用户注册发送邮件验证码,如果不启用用户注册或不启用注册验证,可以不配置
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
# 新建地址限流配置 /api/new_address
# [[unsafe.bindings]]
# name = "RATE_LIMITER"

View File

@@ -38,3 +38,10 @@
7. 点击 `Settings` -> `Variables`, 下拉找到 `D1 Database`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Deploy`
![worker-d1](/ui_install/worker-d1.png)
8. 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤,点击 `Workers & Pages` -> `KV` -> `Create Namespace`, 如图,点击 `Create Namespace`,然后在 `Settings` -> `Variables`, 下拉找到 `KV`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 `KV` 缓存,点击 `Deploy`
> [!NOTE]
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
![worker-kv](/ui_install/worker-kv.png)
![worker-kv-bind](/ui_install/worker-kv-bind.png)

View File

@@ -0,0 +1,135 @@
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains } from '../utils';
import { UserSettings, GeoData, UserInfo } from "../models";
export default {
getSetting: async (c) => {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
return c.json(settings)
},
saveSetting: async (c) => {
const value = await c.req.json();
const settings = new UserSettings(value);
if (settings.enableMailVerify && !c.env.KV) {
return c.text("Please enable KV first if you want to enable mail verify", 403)
}
if (settings.enableMailVerify) {
const mailDomain = settings.verifyMailSender.split("@")[1];
const domains = getDomains(c);
if (!domains.includes(mailDomain)) {
return c.text(`VerifyMailSender(${settings.verifyMailSender}) domain must in ${JSON.stringify(domains, null, 2)}`, 400)
}
}
if (settings.maxAddressCount < 0) {
return c.text("Invalid maxAddressCount", 400)
}
await saveSetting(c, CONSTANTS.USER_SETTINGS_KEY, JSON.stringify(settings));
return c.json({ success: true })
},
getUsers: async (c) => {
const { limit, offset, query } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (query) {
const { results } = await c.env.DB.prepare(
`SELECT u.id, u.user_email, u.created_at, u.updated_at,`
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
+ ` FROM users u`
+ ` where u.user_email like ?`
+ ` order by u.id desc limit ? offset ?`
).bind(`%${query}%`, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users where user_email like ?`
).bind(`%${query}%`).first();
count = userCount;
}
return c.json({
results: results,
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT u.id, u.user_email, u.created_at, u.updated_at,`
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
+ ` FROM users u`
+ ` order by u.id desc limit ? offset ?`
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users`
).first();
count = userCount;
}
return c.json({
results: results,
count: count
})
},
createUser: async (c) => {
const { email, password } = await c.req.json();
if (!email || !password) {
return c.text("Invalid email or password", 400)
}
// geo data
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
const geoData = new GeoData(reqIp, c.req.raw.cf);
const userInfo = new UserInfo(geoData);
try {
checkUserPassword(password);
const { success } = await c.env.DB.prepare(
`INSERT INTO users (user_email, password, user_info)`
+ ` VALUES (?, ?, ?)`
).bind(
email, password, JSON.stringify(userInfo)
).run();
if (!success) {
return c.text("Failed to register", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("User already exists", 400)
}
return c.text(`Failed to register: ${e.message}`, 500)
}
return c.json({ success: true })
},
deleteUser: async (c) => {
const { user_id } = c.req.param();
if (!user_id) return c.text("Invalid user_id", 400);
const { success } = await c.env.DB.prepare(
`DELETE FROM users WHERE id = ?`
).bind(user_id).run();
const { success: addressSuccess } = await c.env.DB.prepare(
`DELETE FROM users_address WHERE user_id = ?`
).bind(user_id).run();
if (!success || !addressSuccess) {
return c.text("Failed to delete user", 500)
}
return c.json({ success: true })
},
resetPassword: async (c) => {
const { user_id } = c.req.param();
const { password } = await c.req.json();
if (!user_id) return c.text("Invalid user_id", 400);
try {
checkUserPassword(password);
const { success } = await c.env.DB.prepare(
`UPDATE users SET password = ? WHERE id = ?`
).bind(password, user_id).run();
if (!success) {
return c.text("Failed to reset password", 500)
}
} catch (e) {
return c.text(`Failed to reset password: ${e.message}`, 500)
}
return c.json({ success: true });
},
}

View File

@@ -1,6 +1,7 @@
import { cleanup } from '../common';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting } from '../utils';
import { CleanupSettings } from '../models';
export default {
cleanup: async (c) => {
@@ -15,11 +16,13 @@ export default {
},
getCleanup: async (c) => {
const value = await getJsonSetting(c, CONSTANTS.AUTO_CLEANUP_KEY);
return c.json(value || {})
const cleanupSetting = new CleanupSettings(value);
return c.json(cleanupSetting)
},
saveCleanup: async (c) => {
const value = await c.req.json();
await saveSetting(c, CONSTANTS.AUTO_CLEANUP_KEY, JSON.stringify(value));
const cleanupSetting = new CleanupSettings(value);
await saveSetting(c, CONSTANTS.AUTO_CLEANUP_KEY, JSON.stringify(cleanupSetting));
return c.json({ success: true })
}
}

View File

@@ -1,9 +1,10 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { sendAdminInternalMail, getJsonSetting, saveSetting } from './utils'
import { newAddress } from './common'
import { CONSTANTS } from './constants'
import cleanup_api from './admin/cleanup_api'
import { sendAdminInternalMail, getJsonSetting, saveSetting } from '../utils'
import { newAddress } from '../common'
import { CONSTANTS } from '../constants'
import cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api'
const api = new Hono()
@@ -83,8 +84,11 @@ api.delete('/admin/delete_address/:id', async (c) => {
`DELETE FROM address_sender WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
const { success: usersAddressSuccess } = await c.env.DB.prepare(
`DELETE FROM users_address WHERE address_id = ?`
).bind(id).run();
return c.json({
success: success && mailSuccess && sendAccess
success: success && mailSuccess && sendAccess && usersAddressSuccess
})
})
@@ -98,11 +102,10 @@ api.get('/admin/show_password/:id', async (c) => {
address_id: id
}, c.env.JWT_SECRET)
return c.json({
password: jwt
jwt: jwt
})
})
api.get('/admin/mails', async (c) => {
const { address, limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
@@ -296,14 +299,11 @@ api.get('/admin/statistics', async (c) => {
})
});
api.post('/admin/cleanup', cleanup_api.cleanup)
api.get('/admin/auto_cleanup', cleanup_api.getCleanup)
api.post('/admin/auto_cleanup', cleanup_api.saveCleanup)
api.get('/admin/account_settings', async (c) => {
try {
/** @type {Array<string>|undefined|null} */
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
/** @type {Array<string>|undefined|null} */
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
return c.json({
blockList: blockList || [],
@@ -316,6 +316,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 } = await c.req.json();
if (!blockList || !sendBlockList) {
return c.text("Invalid blockList or sendBlockList", 400)
@@ -333,4 +334,14 @@ api.post('/admin/account_settings', async (c) => {
})
})
api.post('/admin/cleanup', cleanup_api.cleanup)
api.get('/admin/auto_cleanup', cleanup_api.getCleanup)
api.post('/admin/auto_cleanup', cleanup_api.saveCleanup)
api.get('/admin/user_settings', admin_user_api.getSetting)
api.post('/admin/user_settings', admin_user_api.saveSetting)
api.get('/admin/users', admin_user_api.getUsers)
api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
export { api }

View File

@@ -2,4 +2,5 @@ export const CONSTANTS = {
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
SEND_BLOCK_LIST_KEY: 'send_block_list',
AUTO_CLEANUP_KEY: 'auto_cleanup',
USER_SETTINGS_KEY: 'user_settings',
}

View File

@@ -1,5 +1,4 @@
import { createMimeMessage } from "mimetext";
import { EmailMessage } from "cloudflare:email";
import { getBooleanValue } from "./utils";
async function email(message, env, ctx) {
@@ -40,7 +39,7 @@ async function email(message, env, ctx) {
contentType: 'text/plain',
data: results.message || "This is an auto-reply message, please reconact later."
});
const { EmailMessage } = await import('cloudflare:email');
const replyMessage = new EmailMessage(
message.to,
message.from,

View File

@@ -25,7 +25,7 @@ export default {
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
return c.text("Auto reply is disabled", 403)
}
const { address } = c.get("jwtPayload")
const { address } = c.get("jwtPayload");
const { auto_reply } = await c.req.json();
const { name, subject, source_prefix, message, enabled } = auto_reply;
if ((!subject || !message) && enabled) {

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'
import auto_reply from './user/auto_reply'
import auto_reply from './auto_reply'
const api = new Hono()

View File

@@ -1,7 +1,8 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { CONSTANTS } from './constants'
import { getJsonSetting } from './utils';
import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains } from '../utils';
import { GeoData } from '../models'
const api = new Hono()
@@ -29,37 +30,43 @@ api.post('/api/requset_send_mail_access', async (c) => {
return c.json({ status: "ok" })
})
const sendMail = async (c, address) => {
export const sendMail = async (c, address, reqJson) => {
if (!address) {
throw new Error("No address")
}
// check domain
const mailDomain = address.split("@")[1];
const domains = getDomains(c);
if (!domains.includes(mailDomain)) {
throw new Error("Invalid domain")
}
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1`
).bind(address).first("balance");
if (!balance || balance <= 0) {
return c.text("No balance", 400);
throw new Error("No balance")
}
let {
from_name, to_mail, to_name,
subject, content, is_html
} = await c.req.json();
if (!address) {
return c.text("No address", 400)
}
} = reqJson;
if (!to_mail) {
return c.text("Invalid to mail", 400)
throw new Error("Invalid to mail")
}
// check SEND_BLOCK_LIST_KEY
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
if (sendBlockList && sendBlockList.some((item) => to_mail.includes(item))) {
return c.text("to_mail address is blocked", 400);
throw new Error("to_mail address is blocked")
}
from_name = from_name || address;
to_name = to_name || to_mail;
if (!subject) {
return c.text("Invalid subject", 400)
throw new Error("Invalid subject")
}
if (!content) {
return c.text("Invalid content", 400)
throw new Error("Invalid content")
}
let dmikBody = {}
if (c.env.DKIM_SELECTOR && c.env.DKIM_PRIVATE_KEY && address.includes("@")) {
@@ -100,7 +107,7 @@ const sendMail = async (c, address) => {
const respText = await resp.text();
console.log(resp.status + " " + resp.statusText + ": " + respText);
if (resp.status >= 300) {
return c.text("Failed to send mail", 500)
throw new Error(`Mailchannels error: ${resp.status} ${respText}`);
}
// update balance
try {
@@ -120,7 +127,8 @@ const sendMail = async (c, address) => {
delete body.personalizations[0].dkim_private_key;
}
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
body.reqIp = reqIp;
const geoData = new GeoData(reqIp, c.req.raw.cf);
body.geoData = geoData;
const { success: success2 } = await c.env.DB.prepare(
`INSERT INTO sendbox (address, raw) VALUES (?, ?)`
).bind(address, JSON.stringify(body)).run();
@@ -130,12 +138,18 @@ const sendMail = async (c, address) => {
} catch (e) {
console.warn(`Failed to save to sendbox for ${address}`);
}
return c.json({ status: "ok" });
}
api.post('/api/send_mail', async (c) => {
const { address } = c.get("jwtPayload")
return await sendMail(c, address);
const reqJson = await c.req.json();
try {
await sendMail(c, address, reqJson);
} catch (e) {
console.error("Failed to send mail", e);
return c.text(`Failed to send mail ${e.message}`, 400)
}
return c.json({ status: "ok" })
})
api.post('/external/api/send_mail', async (c) => {
@@ -145,10 +159,11 @@ api.post('/external/api/send_mail', async (c) => {
if (!address) {
return c.text("No address", 400)
}
return await sendMail(c, address);
const reqJson = await c.req.json();
return await sendMail(c, address, reqJson);
} catch (e) {
console.error("Failed to verify token", e);
return c.text("Unauthorized", 401)
console.error("Failed to send mail", e);
return c.text(`Failed to send mail ${e.message}`, 400)
}
})

View File

@@ -0,0 +1,92 @@
export class UserSettings {
/** @param {UserSettings|undefined|null} data */
constructor(data) {
if (data === null) {
return;
}
const {
enable, enableMailVerify, verifyMailSender,
enableMailAllowList, mailAllowList, maxAddressCount
} = data || {};
/** @type {boolean|undefined} */
this.enable = enable;
/** @type {boolean|undefined} */
this.enableMailVerify = enableMailVerify;
/** @type {string|undefined} */
this.verifyMailSender = verifyMailSender;
/** @type {boolean|undefined} */
this.enableMailAllowList = enableMailAllowList;
/** @type {Array<string>|undefined} */
this.mailAllowList = mailAllowList;
/** @type {number|undefined} */
this.maxAddressCount = maxAddressCount || 5;
}
}
export class CleanupSettings {
/** @param {CleanupSettings|undefined|null} data */
constructor(data) {
const {
enableMailsAutoCleanup, cleanMailsDays,
enableUnknowMailsAutoCleanup, cleanUnknowMailsDays,
enableAddressAutoCleanup, cleanAddressDays,
enableSendBoxAutoCleanup, cleanSendBoxDays
} = data || {};
/** @type {boolean|undefined} */
this.enableMailsAutoCleanup = enableMailsAutoCleanup;
/** @type {number|undefined} */
this.cleanMailsDays = cleanMailsDays;
/** @type {boolean|undefined} */
this.enableUnknowMailsAutoCleanup = enableUnknowMailsAutoCleanup;
/** @type {number|undefined} */
this.cleanUnknowMailsDays = cleanUnknowMailsDays;
/** @type {boolean|undefined} */
this.enableAddressAutoCleanup = enableAddressAutoCleanup;
/** @type {number|undefined} */
this.cleanAddressDays = cleanAddressDays;
/** @type {boolean|undefined} */
this.enableSendBoxAutoCleanup = enableSendBoxAutoCleanup;
/** @type {number|undefined} */
this.cleanSendBoxDays = cleanSendBoxDays;
}
}
export class GeoData {
/** @param {string} ip @param {GeoData|undefined|null} data */
constructor(ip, data) {
const {
country, city, timezone, postalCode, region,
latitude, longitude, regionCode, asOrganization
} = data || {};
/** @type {string} */
this.ip = ip;
/** @type {string|undefined} */
this.country = country;
/** @type {string|undefined} */
this.city = city;
/** @type {string|undefined} */
this.timezone = timezone;
/** @type {string|undefined} */
this.postalCode = postalCode;
/** @type {string|undefined} */
this.region = region;
/** @type {number|undefined} */
this.latitude = latitude;
/** @type {number|undefined} */
this.longitude = longitude;
/** @type {string|undefined} */
this.regionCode = regionCode;
/** @type {string|undefined} */
this.asOrganization = asOrganization;
}
}
export class UserInfo {
/** @param {GeoData} geoData @param {string} userEmail */
constructor(geoData, userEmail) {
/** @type {geoData} */
this.geoData = geoData;
/** @type {string} */
this.userEmail = userEmail;
}
}

View File

@@ -153,7 +153,7 @@ api.delete('/api/delete_address', async (c) => {
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text("User delete email is disabled", 403)
}
const { address } = c.get("jwtPayload")
const { address, address_id } = c.get("jwtPayload")
let name = address;
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE name = ? `
@@ -170,8 +170,11 @@ api.delete('/api/delete_address', async (c) => {
const { success: sendAccess } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE address = ? `
).bind(address).run();
const { success: addressSuccess } = await c.env.DB.prepare(
`DELETE FROM users_address WHERE address_id = ?`
).bind(address_id).run();
return c.json({
success: success && mailSuccess && sendAccess
success: success && mailSuccess && sendAccess && addressSuccess
})
})

View File

@@ -1,15 +1,16 @@
import { cleanup } from './common'
import { CONSTANTS } from './constants'
import { getJsonSetting } from './utils';
import { CleanupSettings } from './models';
export async function scheduled(event, env, ctx) {
console.log("Scheduled event: ", event);
let autoCleanupSetting = await getJsonSetting(
const value = await getJsonSetting(
{ env: env, },
CONSTANTS.AUTO_CLEANUP_KEY
);
const autoCleanupSetting = new CleanupSettings(value);
console.log("autoCleanupSetting:", JSON.stringify(autoCleanupSetting));
autoCleanupSetting = autoCleanupSetting || {};
if (autoCleanupSetting.enableMailsAutoCleanup && autoCleanupSetting.cleanMailsDays > 0) {
await cleanup(
{ env: env, },

View File

@@ -0,0 +1,139 @@
import { Jwt } from 'hono/utils/jwt'
import { UserSettings } from "../models";
import { getJsonSetting } from "../utils"
import { CONSTANTS } from "../constants";
export default {
bind: async (c) => {
const { user_id } = c.get("userPayload");
const { address_id } = c.get("jwtPayload");
if (!address_id || !user_id) {
return c.text("No address or user token", 400)
}
// check if address exists
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ?`
).bind(address_id).first("id");
if (!db_address_id) {
return c.text("Address not found", 400)
}
// check if user exists
const db_user_id = await c.env.DB.prepare(
`SELECT id FROM users where id = ?`
).bind(user_id).first("id");
if (!db_user_id) {
return c.text("User not found", 400)
}
// check if binded
const db_user_address_id = await c.env.DB.prepare(
`SELECT user_id FROM users_address where user_id = ? and address_id = ?`
).bind(user_id, address_id).first("user_id");
if (db_user_address_id) return c.json({ success: true })
// check if binded address count
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
if (settings.maxAddressCount > 0) {
const { count } = await c.env.DB.prepare(
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
).bind(user_id).first();
if (count >= settings.maxAddressCount) {
return c.text("Max address count reached", 400)
}
}
// bind
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO users_address (user_id, address_id) VALUES (?, ?)`
).bind(user_id, address_id).run();
if (!success) {
return c.text("Failed to bind", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("Address already binded, please unbind first", 400)
}
return c.text("Failed to bind", 500)
}
return c.json({ success: true })
},
unbind: async (c) => {
const { user_id } = c.get("userPayload");
const { address_id } = await c.req.json();
if (!address_id || !user_id) {
return c.text("Invalid address or user token", 400)
}
// check if address exists
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ?`
).bind(address_id).first("id");
if (!db_address_id) {
return c.text("Address not found", 400)
}
// check if user exists
const db_user_id = await c.env.DB.prepare(
`SELECT id FROM users where id = ?`
).bind(user_id).first("id");
if (!db_user_id) {
return c.text("User not found", 400)
}
// unbind
try {
const { success } = await c.env.DB.prepare(
`DELETE FROM users_address where user_id = ? and address_id = ?`
).bind(user_id, address_id).run();
if (!success) {
return c.text("Failed to unbind", 500)
}
} catch (e) {
return c.text("Invalid address token", 400)
}
return c.json({ success: true })
},
getBindedAddresses: async (c) => {
const { user_id } = c.get("userPayload");
if (!user_id) {
return c.text("No user token", 400)
}
// select binded address
const { results } = await c.env.DB.prepare(
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a `
+ ` JOIN users_address ua `
+ ` ON ua.address_id = a.id `
+ ` WHERE ua.user_id = ?`
+ ` ORDER BY a.id DESC`
).bind(user_id).all();
return c.json({
results: results,
})
},
getBindedAddressJwt: async (c) => {
const { address_id } = c.req.param();
// check binded
const { user_id } = c.get("userPayload");
if (!address_id || !user_id) {
return c.text("Invalid address or user token", 400)
}
// check users_address if address binded
const db_user_id = await c.env.DB.prepare(
`SELECT user_id FROM users_address WHERE address_id = ? and user_id = ?`
).bind(address_id, user_id).first("user_id");
if (!db_user_id) {
return c.text("Address not binded", 400)
}
// generate jwt
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(address_id).first("name");
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET)
return c.json({
jwt: jwt
})
},
}

View File

@@ -0,0 +1,19 @@
import { Hono } from 'hono';
import settings from './settings';
import user from './user';
import bind_address from './bind_address';
const api = new Hono();
api.get('/user_api/open_settings', settings.openSettings);
api.get('/user_api/settings', settings.settings);
api.post('/user_api/login', user.login);
api.post('/user_api/verify_code', user.verifyCode);
api.post('/user_api/register', user.register);
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);
export { api }

View File

@@ -0,0 +1,25 @@
import { UserSettings } from "../models";
import { getJsonSetting } from "../utils"
import { CONSTANTS } from "../constants";
export default {
openSettings: async (c) => {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
return c.json({
enable: settings.enable,
enableMailVerify: settings.enableMailVerify,
})
},
settings: async (c) => {
const user = c.get("userPayload");
// check if user exists
const db_user_id = await c.env.DB.prepare(
`SELECT id FROM users where id = ?`
).bind(user.user_id).first("id");
if (!db_user_id) {
return c.text("User not found", 400);
}
return c.json(user);
},
}

141
worker/src/user_api/user.js Normal file
View File

@@ -0,0 +1,141 @@
import { Jwt } from 'hono/utils/jwt'
import { checkCfTurnstile, getJsonSetting, checkUserPassword } from "../utils"
import { CONSTANTS } from "../constants";
import { GeoData, UserInfo, UserSettings } from "../models";
import { sendMail } from "../mails_api/send_mail_api";
export default {
verifyCode: async (c) => {
const { email, 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)
}
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value)
// check mail domain allow list
const mailDomain = email.split("@")[1];
if (settings.enableMailAllowList
&& settings.mailAllowList
&& !settings.mailAllowList.includes(mailDomain)
) {
return c.text(`Mail domain must in ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
}
// check if code exists in KV
const tmpcode = await c.env.KV.get(`temp-mail:${email}`)
if (tmpcode) {
return c.text("Code already sent, please wait", 400)
}
// generate code 6 digits and convert to string
const code = Math.floor(100000 + Math.random() * 900000).toString();
// send code to email
try {
await sendMail(c, settings.verifyMailSender, {
to_mail: email,
subject: "Temp Mail Verify code",
content: `Your verify code is ${code}`,
})
} catch (e) {
return c.text(`Failed to send verify code: ${e.message}`, 500)
}
// save to KV
await c.env.KV.put(`temp-mail:${email}`, code, { expirationTtl: 300 });
return c.json({
success: true,
expirationTtl: 300
})
},
register: async (c) => {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value)
// check enable
if (!settings.enable) {
return c.text("User registration is disabled");
}
// check request
const { email, password, code } = await c.req.json();
if (!email || !password) {
return c.text("Invalid email or password", 400)
}
checkUserPassword(password);
if (settings.enableMailVerify && !code) {
return c.text("Need verify code", 400)
}
// check mail domain allow list
const mailDomain = email.split("@")[1];
if (settings.enableMailAllowList
&& !settings.mailAllowList.includes(mailDomain)
) {
return c.text(`Mail domain must in ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
}
// check code
if (settings.enableMailVerify) {
const verifyCode = await c.env.KV.get(`temp-mail:${email}`)
if (verifyCode != code) {
return c.text("Invalid verify code", 400)
}
}
// geo data
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
const geoData = new GeoData(reqIp, c.req.raw.cf);
const userInfo = new UserInfo(geoData);
// if not enable mail verify, do not on conflict update
if (!settings.enableMailVerify) {
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO users (user_email, password, user_info)`
+ ` VALUES (?, ?, ?)`
).bind(
email, password, JSON.stringify(userInfo)
).run();
if (!success) {
return c.text("Failed to register", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("User already exists, please login", 400)
}
return c.text(`Failed to register: ${e.message}`, 500)
}
return c.json({ success: true })
}
// if enable mail verify, on conflict update
const { success } = await c.env.DB.prepare(
`INSERT INTO users (user_email, password, user_info)`
+ ` VALUES (?, ?, ?)`
+ ` ON CONFLICT(user_email) DO UPDATE SET password = ?, user_info = ?, updated_at = datetime('now')`
).bind(
email, password, JSON.stringify(userInfo),
password, JSON.stringify(userInfo)
).run();
if (!success) {
return c.text("Failed to register", 500)
}
return c.json({ success: true })
},
login: async (c) => {
const { email, password } = await c.req.json();
if (!email || !password) return c.text("Invalid email or password", 400);
const { id: user_id, password: dbPassword } = await c.env.DB.prepare(
`SELECT id, password FROM users where user_email = ?`
).bind(email).first() || {};
if (!dbPassword) {
return c.text("User not found", 400)
}
// TODO: need check password use random salt
if (dbPassword != password) {
return c.text("Invalid password", 400)
}
// create jwt
const jwt = await Jwt.sign({
user_email: email,
user_id: user_id
}, c.env.JWT_SECRET)
return c.json({
jwt: jwt
})
},
}

View File

@@ -153,3 +153,10 @@ export const checkCfTurnstile = async (c, token) => {
throw new Error("Captcha failed");
}
}
export const checkUserPassword = (password) => {
if (!password || password.length < 1 || password.length > 100) {
throw new Error("Invalid password")
}
return true;
}

View File

@@ -1,28 +1,31 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors';
import { jwt } from 'hono/jwt'
import { Jwt } from 'hono/utils/jwt'
import { api } from './router';
import { api as MailsApi } from './mails_api'
import { api as userApi } from './user_api';
import { api as adminApi } from './admin_api';
import { api as apiV1 } from './api_v1';
import { api as apiSendMail } from './send_mail_api'
import { api as apiV1 } from './deprecated';
import { api as apiSendMail } from './mails_api/send_mail_api'
import { email } from './email';
import { scheduled } from './scheduled';
import { getAdminPasswords, getPasswords } from './utils';
const app = new Hono()
//cors
app.use('/*', cors());
app.use('/api/*', async (c, next) => {
// check header x-custom-auth
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !passwords.includes(auth)) {
return c.text("Need Password", 401)
}
}
if (c.req.path.startsWith("/api/new_address") || c.req.path.startsWith("/api/send_mail")) {
// rate limit
app.use('/*', async (c, next) => {
if (
c.req.path.startsWith("/api/new_address")
|| c.req.path.startsWith("/api/send_mail")
|| c.req.path.startsWith("/external/api/send_mail")
|| c.req.path.startsWith("/user_api/register")
|| c.req.path.startsWith("/user_api/verify_code")
) {
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
if (reqIp && c.env.RATE_LIMITER) {
const { success } = await c.env.RATE_LIMITER.limit(
@@ -33,13 +36,51 @@ app.use('/api/*', async (c, next) => {
}
}
}
await next()
});
// api auth
app.use('/api/*', async (c, next) => {
// check header x-custom-auth
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !passwords.includes(auth)) {
return c.text("Need Password", 401)
}
}
if (c.req.path.startsWith("/api/new_address")) {
await next();
return;
}
return jwt({ secret: c.env.JWT_SECRET })(c, next);
});
// user_api auth
app.use('/user_api/*', async (c, next) => {
if (
c.req.path.startsWith("/user_api/open_settings")
|| c.req.path.startsWith("/user_api/register")
|| c.req.path.startsWith("/user_api/login")
|| c.req.path.startsWith("/user_api/verify_code")
) {
await next();
return;
}
try {
const token = c.req.raw.headers.get("x-user-token");
const payload = await Jwt.verify(token, c.env.JWT_SECRET);
c.set("userPayload", payload);
} catch (e) {
console.error(e);
return c.text("Need User Token", 401)
}
if (c.req.path.startsWith('/user_api/bind_address')
&& c.req.method === 'POST'
) {
return jwt({ secret: c.env.JWT_SECRET })(c, next);
}
await next();
});
// admin auth
app.use('/admin/*', async (c, next) => {
// check header x-admin-auth
const adminPasswords = getAdminPasswords(c);
@@ -55,6 +96,7 @@ app.use('/admin/*', async (c, next) => {
app.route('/', api)
app.route('/', MailsApi)
app.route('/', userApi)
app.route('/', adminApi)
app.route('/', apiV1)

View File

@@ -44,6 +44,11 @@ binding = "DB"
database_name = "xxx"
database_id = "xxx"
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
# ratelimit config for /api/new_address
# [[unsafe.bindings]]
# name = "RATE_LIMITER"