新增权限域名限制和链接背景上传

This commit is contained in:
eoao
2025-07-26 23:11:00 +08:00
parent 0e86b93e0c
commit 12833cd901
36 changed files with 584 additions and 354 deletions

View File

@@ -161,9 +161,9 @@ After the update, run `https://your-project-domain/api/init/your-jwt-secret` to
## Support
**Buy me a cup of milk tea**
<a href="https://afdian.com/a/eoao_"><img width="150" src="https://pic1.afdiancdn.com/static/img/welcome/button-sponsorme.png" alt=""></a>
<a href="https://support.skymail.ink">
<img width="170px" src="./doc/images/support.png" alt="">
</a><br><br>
**Special Sponsors**

View File

@@ -182,9 +182,10 @@ jwt_secret = "" #登录身份令牌的密钥,随便填一串字符串
## 赞助
**请我喝一杯奶茶**
<a href="https://afdian.com/a/eoao_"><img width="150" src="https://pic1.afdiancdn.com/static/img/welcome/button-sponsorme.png" alt=""></a>
<a href="https://support.skymail.ink" >
<img width="170px" src="./doc/images/support.png" alt="">
</a><br><br>
**特别赞助商**

BIN
doc/images/support.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -22,7 +22,6 @@
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"vue": "^3.5.13",
"vue-cropper": "^1.1.4",
"vue-i18n": "^11.1.10",
"vue-router": "^4.5.0"
},
@@ -3842,12 +3841,6 @@
}
}
},
"node_modules/vue-cropper": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/vue-cropper/-/vue-cropper-1.1.4.tgz",
"integrity": "sha512-5m98vBsCEI9rbS4JxELxXidtAui3qNyTHLHg67Qbn7g8cg+E6LcnC+hh3SM/p94x6mFh6KRxT1ttnta+wCYqWA==",
"license": "ISC"
},
"node_modules/vue-i18n": {
"version": "11.1.10",
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.1.10.tgz",

View File

@@ -24,7 +24,6 @@
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"vue": "^3.5.13",
"vue-cropper": "^1.1.4",
"vue-i18n": "^11.1.10",
"vue-router": "^4.5.0"
},

View File

@@ -7,7 +7,7 @@
import { useI18n } from "vue-i18n";
import {useSettingStore} from "@/store/setting.js";
const settingStore = useSettingStore()
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import zhCn from 'element-plus/es/locale/lang/zh-cn';;
const { locale } = useI18n()
locale.value = settingStore.lang
</script>

View File

@@ -96,7 +96,6 @@ const en = {
expand: 'Expand',
collapse: 'Collapse',
daily: 'Daily',
emailBlock: '*Intercept all incoming emails to example.com',
searchRegKeyDesc: 'Enter invite code to search',
remainingUses: 'Remaining uses',
exhausted: 'Exhausted',
@@ -168,11 +167,11 @@ const en = {
addOsDomain: 'Add domain',
domainDesc: 'Domain',
addTurnstileSecret: 'Add turnstile secret',
backgroundCropping: 'Background cropping',
backgroundTitle: 'Change background',
tgBotDesc: 'Forward received emails to a Telegram bot',
tgBotToken: 'Bot token',
toBotTokenDesc: 'Multiple user chat_ids, separated by commas',
otherEmailDec: 'emails can be forwarded to external email, but must be verified via cloudflare.',
otherEmailDesc: 'emails can be forwarded to external email, but must be verified via cloudflare.',
otherEmailInputDesc: 'Separate multiple email addresses with commas.',
forwardingRulesDesc: 'Rule-based forwarding only forwards emails received by the specified address.',
ruleEmailsInputDesc: 'Separate multiple email addresses with commas.',
@@ -246,7 +245,17 @@ const en = {
totalUserAccount: '{msg}',
sendBanned: 'Banned',
wrote: 'wrote',
support: 'Support'
support: 'Support',
supportDesc: 'Buy me tea',
featDesc: 'Feature Description',
emailInterception: 'Email Interception',
emailInterceptionDesc: '*Intercept emails by blocking entire domain using example.com to prevent users from receiving emails from certain websites.',
availableDomains: 'Available domains',
availableDomainsDesc: 'Restrict users to email domains specified. Domains not on the approved list will be blocked from registration, adding email addresses, and sending/receiving emails. If left blank, all domains will be allowed by default.',
backgroundUrlDesc: 'Image URL',
localUpload: ' Local upload',
imageLink: 'Image URL',
backgroundWarning: 'Image file size affects website load speed.'
}
export default en

View File

@@ -96,7 +96,6 @@ const zh = {
expand: '展开',
collapse: '收起',
daily: '每天',
emailBlock: '输入邮箱拦截收件, 拦截所有前缀 *example.com',
searchRegKeyDesc: '输入注册码搜索',
remainingUses: '剩余次数',
exhausted: '已用尽',
@@ -165,14 +164,14 @@ const zh = {
community: '交流',
changeTitle: '修改标题',
addResendTokenDesc: '输入内容添加,不填则删除',
addOsDomain: '添加访对象存储问域名',
addOsDomain: '添加访问域名',
domainDesc: '域名',
addTurnstileSecret: '添加 Turnstile 密钥',
backgroundCropping: '背景截图',
backgroundTitle: '设置背景',
tgBotDesc: '可以将接收的邮件转发到Tg机器人',
tgBotToken: '机器人 token',
toBotTokenDesc: '用户 chat_id 多个用,分开',
otherEmailDec: '可以将邮件转到其他服务商邮箱需要在cloudflare验证邮箱',
otherEmailDesc: '可以将邮件转到其他服务商邮箱需要在cloudflare验证邮箱',
otherEmailInputDesc: '多个邮箱用, 分开',
forwardingRulesDesc: '规则转发只会转发设置邮箱所接收的邮件',
ruleEmailsInputDesc: '多个邮箱用, 分开',
@@ -246,6 +245,16 @@ const zh = {
totalUserAccount: '{msg} 个',
sendBanned: '已禁用',
wrote: '来信',
support: '赞助'
support: '赞助',
supportDesc: '请我喝杯奶茶',
featDesc: '功能说明',
emailInterception: '邮件拦截',
emailInterceptionDesc: '拦截邮件, 要拦截整个域名输入 *example.com, 可用于禁止用户接收某些网站的邮件',
availableDomains: '可用域名',
availableDomainsDesc: '限制用户只能使用指定的域名邮箱,不在配置名单内的域名会被禁止使用注册添加邮箱,接收发送邮件等功能,留空默认允许可用所有域名',
backgroundUrlDesc: '在线图片链接',
localUpload: '本地上传',
imageLink: '图片链接',
backgroundWarning: '图片文件大小会影响网站加载速度',
}
export default zh

View File

@@ -61,8 +61,8 @@
</el-menu-item>
<el-menu-item @click="router.push({name: 'sys-setting'})" index="sys-setting" v-perm="'setting:query'"
:class="route.meta.name === 'sys-setting' ? 'choose-item' : ''">
<Icon icon="eos-icons:system-ok-outlined" width="18" height="18" />
<span class="menu-name" style="margin-left: 24px">{{$t('SystemSettings')}}</span>
<Icon icon="eos-icons:system-ok-outlined" width="18" height="18" style="margin-left: 2px" />
<span class="menu-name" style="margin-left: 22px">{{$t('SystemSettings')}}</span>
</el-menu-item>
</el-menu>
</div>

View File

@@ -38,7 +38,6 @@ const handleResize = () => {
}
onMounted(() => {
uiStore.writerRef = writerRef
window.addEventListener('resize', handleResize)

View File

@@ -2,8 +2,6 @@ import {createApp} from 'vue';
import App from './App.vue';
import router from './router';
import './style.css';
import VueCropper from 'vue-cropper';
import 'vue-cropper/dist/index.css'
import { init } from '@/init/init.js';
import { createPinia } from 'pinia';
import piniaPersistedState from 'pinia-plugin-persistedstate';
@@ -12,7 +10,7 @@ const pinia = createPinia().use(piniaPersistedState)
import i18n from "@/i18n/index.js";
const app = createApp(App).use(pinia)
await init()
app.use(router).use(VueCropper).use(i18n).directive('perm',perm)
app.use(router).use(i18n).directive('perm',perm)
app.config.devtools = true;
app.mount('#app');

View File

@@ -1,5 +1,24 @@
import {useSettingStore} from "@/store/setting.js";
export function cvtR2Url(key) {
const settingStore = useSettingStore();
return settingStore.settings.r2Domain + '/' + key
if (!key) {
return + 'https://' + ''
}
if (key.startsWith('https://')) {
return key
}
const { settings } = useSettingStore();
let domain = settings.r2Domain
if (!domain.startsWith('http')) {
return 'https://' + domain + '/' + key
}
if (domain.endsWith("/")) {
domain = domain.slice(0, -1);
}
return domain + '/' + key
}

View File

@@ -40,11 +40,11 @@ export function fromNow(date) {
if (diffSeconds < 60) return `Just now`;
if (diffMinutes < 60) return `${diffMinutes} min ago`;
if (diffHours < 2) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
return d.format('HH:mm');
return d.format('hh:mm A');
}
if (now.subtract(1, 'day').isSame(d, 'day')) {
return d.format('hh:mm A');
return d.format('MMM D');
}
return d.year() === now.year()

View File

@@ -14,13 +14,18 @@ export function formatBytes(bytes) {
return `${size} ${units[i]}`;
}
export function fileToBase64(file) {
export function fileToBase64(file, type = false) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const base64 = reader.result.split(',')[1];
resolve(base64);
if (type) {
const base64 = reader.result;
resolve(base64);
} else {
const base64 = reader.result.split(',')[1];
resolve(base64);
}
};
reader.onerror = reject;
});
@@ -50,4 +55,4 @@ export function compressImage(file, config = {}) {
},
});
});
}
}

View File

@@ -47,15 +47,52 @@
</el-table>
</div>
</el-scrollbar>
<el-dialog top="5vh" class="dialog" v-model="roleFormShow" :title="dialogType.title" @closed="resetForm">
<el-dialog top="5vh" class="dialog" v-model="roleFormShow" @closed="resetForm">
<template #header>
<span style="font-size: 18px">{{dialogType.title}}</span>
<el-popover
width ="340"
:title="t('featDesc')"
placement="bottom"
>
<template #reference>
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</template>
<div style="font-weight: bold;margin-bottom: 2px;">{{t('emailInterception')}}</div>
<div>{{t('emailInterceptionDesc')}}</div>
<div style="font-weight: bold;margin-top: 10px;margin-bottom: 2px;">{{t('availableDomains')}}</div>
<div>
{{t('availableDomainsDesc')}}
</div>
</el-popover>
</template>
<div class="dialog-box">
<el-input class="dialog-input" v-model="form.name" type="text" :maxlength="12" :placeholder="$t('roleName')" autocomplete="off" />
<el-input class="dialog-input" v-model="form.description" :maxlength="30" type="text" :placeholder="$t('description')" autocomplete="off" />
<el-input-tag class="dialog-input-tag" tag-type="warning" :class="form.banEmail.length === 0 ? 'dialog-input' : '' " v-model="form.banEmail" @add-tag="banEmailAddTag" type="text" :placeholder="$t('emailBlock')" autocomplete="off" />
<el-input-tag class="dialog-input-tag" tag-type="warning" :class="form.banEmail.length === 0 ? 'dialog-input' : '' " v-model="form.banEmail" @add-tag="banEmailAddTag" type="text" :placeholder="$t('emailInterception')" autocomplete="off" />
<el-radio-group class="dialog-radio" v-model="form.banEmailType" v-if="form.banEmail.length > 0">
<el-radio :label="$t('removeAll')" :value="0" />
<el-radio :label="$t('removeBody')" :value="1" />
</el-radio-group>
<el-select
class="dialog-input"
v-model="form.availDomain"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
tag-type="success"
:placeholder="$t('availableDomains')"
@change="availDomainChange"
>
<el-option
v-for="item in domainOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="dialog-input">
<el-input-number :placeholder="$t('order')" :min="0" :max="9999" v-model.number="form.sort" controls-position="right" autocomplete="off" />
</div>
@@ -108,6 +145,7 @@ import {roleAdd, roleDelete, rolePermTree, roleRoleList, roleSet, roleSetDef} fr
import loading from '@/components/loading/index.vue';
import {useRoleStore} from "@/store/role.js";
import {useUserStore} from "@/store/user.js";
import {useSettingStore} from "@/store/setting.js";
import {isEmail} from "@/utils/verify-utils.js";
import {useI18n} from "vue-i18n";
@@ -115,6 +153,7 @@ defineOptions({
name: 'role'
})
const { domainList } = useSettingStore();
const { t, locale } = useI18n();
const userStore = useUserStore();
const roleStore = useRoleStore();
@@ -144,8 +183,11 @@ const form = reactive({
accountCount: 0,
sort: 0,
isDefault: 0,
availDomain: []
})
let domainOptions = []
const expand = ref(false)
let chooseRole = {}
@@ -156,6 +198,18 @@ rolePermTree().then(tree => {
treeList.push(...tree)
})
domainOptions = domainList.map(domain => ({label: domain,value: domain}))
function availDomainChange() {
const index = form.availDomain.findIndex(domain => {
return !domainOptions.map(option => option.value).includes(domain)
})
if (index > -1) {
form.availDomain.splice(index,1)
}
}
function banEmailAddTag(val) {
const emails = Array.from(new Set(
val.split(/[,]/).map(item => item.trim()).filter(item => item)
@@ -270,6 +324,7 @@ function resetForm() {
form.accountCount = 0
form.banEmail = []
form.banEmailType = 0
form.availDomain = []
tree.value.setCheckedKeys([])
}
@@ -285,6 +340,7 @@ function openRoleSet(role) {
form.sendCount = role.sendCount
form.accountCount = role.accountCount
form.banEmail = role.banEmail
form.availDomain = role.availDomain
nextTick(() => {
tree.value.setCheckedKeys(role.permIds)
})
@@ -394,7 +450,7 @@ window.onresize = () => {
.warning {
position: relative;
left: 5px;
top: 5px;
top: 2px;
color: gray;
cursor: pointer;
}

View File

@@ -73,9 +73,8 @@ import {accountSetName} from "@/request/account.js";
import {useAccountStore} from "@/store/account.js";
import {useI18n} from "vue-i18n";
import {useSettingStore} from "@/store/setting.js";
import dayjs from "dayjs";
const { t, locale } = useI18n()
const { t } = useI18n()
const settingStore = useSettingStore()
const accountStore = useAccountStore()
const userStore = useUserStore();

View File

@@ -101,13 +101,13 @@
fit="cover"
>
<template #error>
<div class="error-image" @click="openCut">
<div class="error-image" @click="openSetBackground">
<Icon icon="ph:image" width="24" height="24"/>
</div>
</template>
</el-image>
<div class="background-btn">
<el-button class="opt-button" size="small" type="primary" @click="openCut">
<el-button class="opt-button" size="small" type="primary" @click="openSetBackground">
<Icon icon="lsicon:edit-outline" width="16" height="16"/>
</el-button>
<el-button class="opt-button" size="small" type="primary" @click="delBackground">
@@ -286,10 +286,10 @@
</div>
<div class="concerning-item">
<span>{{$t('support')}} : </span>
<el-button @click="jump('https://afdian.com/a/eoao_')" >
Afdian
<el-button @click="jump('https://support.skymail.ink')" >
{{t('supportDesc')}}
<template #icon>
<Icon color="#8261DB" icon="simple-icons:afdian" width="24" height="24" />
<Icon color="#79D6B5" icon="simple-icons:buymeacoffee" width="20" height="20" />
</template>
</el-button>
</div>
@@ -299,7 +299,7 @@
</div>
<!-- Dialogs remain the same -->
<el-dialog v-model="editTitleShow" :title="$t('changeTitle')" width="340" @closed="editTitle = ''">
<el-dialog v-model="editTitleShow" :title="$t('changeTitle')" width="340" >
<form>
<el-input type="text" :placeholder="$t('websiteTitle')" v-model="editTitle"/>
<el-button type="primary" :loading="settingLoading" @click="saveTitle">{{$t('save')}}</el-button>
@@ -319,7 +319,7 @@
<el-button type="primary" :loading="settingLoading" @click="saveResendToken">{{$t('save')}}</el-button>
</form>
</el-dialog>
<el-dialog v-model="r2DomainShow" :title="$t('addOsDomain')" width="340" @closed="r2DomainInput = ''">
<el-dialog v-model="r2DomainShow" :title="$t('addOsDomain')" width="340" @closed="r2DomainInput = setting.r2Domain">
<form>
<el-input type="text" :placeholder="$t('domainDesc')" v-model="r2DomainInput"/>
<el-button type="primary" :loading="settingLoading" @click="saveR2domain">{{$t('save')}}</el-button>
@@ -334,24 +334,37 @@
</form>
</el-dialog>
<el-dialog
v-model="cutShow"
:title="$t('backgroundCropping')"
v-model="showSetBackground"
class="cut-dialog"
@closed="closedSetBackground"
>
<div class="cropper">
<vueCropper
ref="cropper"
:img="cutImage"
:fixedNumber="[16,9]"
outputType="jpeg"
:fixed="true"
:centerBox="true"
:full="true"
:autoCrop="true"
:outputSize="0.92"
></vueCropper>
</div>
<template #header>
<span style="font-size: 18px">
{{$t('backgroundTitle')}}
<el-tooltip>
<template #content>
<span>{{$t('backgroundWarning')}}</span>
</template>
<Icon class="title-icon warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</span>
</template>
<el-input :placeholder="$t('backgroundUrlDesc')" v-model="backgroundUrl" v-if="!localUpShow" class="background-url" />
<el-image
v-if="localUpShow"
:preview-src-list="[backgroundImage]"
show-progress
class="cropper"
fit="cover"
:src="backgroundImage"
></el-image>
<div class="cut-button">
<el-button type="primary" link @click="openCut" v-if="!localUpShow">
{{$t('localUpload')}}
</el-button>
<el-button type="primary" link @click="localUpShow = false" v-if="localUpShow">
{{$t('imageLink')}}
</el-button>
<el-button type="primary" :loading="settingLoading" @click="saveBackground">{{$t('save')}}</el-button>
</div>
</el-dialog>
@@ -387,7 +400,7 @@
<template #header>
<div class="forward-head">
<span class="forward-set-title">{{$t('otherEmail')}}</span>
<el-tooltip effect="dark" :content="$t('otherEmailDec')">
<el-tooltip effect="dark" :content="$t('otherEmailDesc')">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
@@ -454,6 +467,7 @@ import {debounce} from 'lodash-es'
import {isEmail} from "@/utils/verify-utils.js";
import loading from "@/components/loading/index.vue";
import {getTextWidth} from "@/utils/text.js";
import { fileToBase64 } from "@/utils/file-utils.js"
import { useI18n } from 'vue-i18n';
defineOptions({
@@ -462,9 +476,8 @@ defineOptions({
const { t, locale } = useI18n();
const firstLoading = ref(true)
const cropper = ref()
const cutImage = ref('')
const cutShow = ref(false)
const backgroundImage = ref('')
const localUpShow = ref(false)
const accountStore = useAccountStore();
const userStore = useUserStore();
const editTitleShow = ref(false)
@@ -481,6 +494,9 @@ const editTitle = ref('')
const settingLoading = ref(false)
const r2DomainInput = ref('')
const loginOpacity = ref(0)
const backgroundUrl = ref('')
let backgroundFile = {}
const showSetBackground = ref(false)
let backup = '{}'
const resendTokenForm = reactive({
domain: '',
@@ -517,6 +533,17 @@ const tokenColumnWidth = ref(0)
const ruleType = ref(0)
const ruleEmail = ref([])
settingQuery().then(settingData => {
setting.value = settingData
resendTokenForm.domain = setting.value.domainList[0]
loginOpacity.value = setting.value.loginOpacity
firstLoading.value = false
backgroundUrl.value = setting.value.background?.startsWith('http') ? setting.value.background : ''
editTitle.value = setting.value.title
r2DomainInput.value = setting.value.r2Domain
})
const resendList = computed(() => {
let list = Object.keys(setting.value.resendTokens).map(key => {
@@ -548,13 +575,11 @@ const compareByLengthAndUpperCase = (a, b, key) => {
};
settingQuery().then(settingData => {
setting.value = settingData
resendTokenForm.domain = setting.value.domainList[0]
loginOpacity.value = setting.value.loginOpacity
firstLoading.value = false
})
function closedSetBackground() {
backgroundImage.value = ''
localUpShow.value = false
backgroundUrl.value = setting.value.background?.startsWith('http') ? setting.value.background : ''
}
function openTgSetting() {
tgBotStatus.value = setting.value.tgBotStatus
@@ -697,6 +722,7 @@ function delBackground() {
cancelButtonText: t('cancel'),
type: 'warning'
}).then(() => {
backgroundUrl.value = ''
setting.value.background = null
editSetting({background: null})
})
@@ -709,21 +735,43 @@ function saveTurnstileKey() {
editSetting(settingForm)
}
function saveBackground() {
settingLoading.value = true
cropper.value.getCropData(data => {
setBackground(data).then(key => {
setting.value.background = key
cutShow.value = false
async function saveBackground() {
let image = ''
if (localUpShow.value) {
image = await fileToBase64(backgroundFile,true);
} else {
if (!backgroundUrl.value.startsWith('http')) {
ElMessage({
message: t('changSuccessMsg'),
type: "success",
message: '图片地址不正确',
type: "error",
plain: true
})
}).finally(() => {
settingLoading.value = false
return
}
image = backgroundUrl.value
}
settingLoading.value = true
setBackground(image).then(key => {
setting.value.background = key
showSetBackground.value = false
ElMessage({
message: t('changSuccessMsg'),
type: "success",
plain: true
})
localUpShow.value = false
backgroundImage.value = ''
}).finally(() => {
settingLoading.value = false
})
}
function openSetBackground() {
showSetBackground.value = true
}
function openCut() {
@@ -732,14 +780,15 @@ function openCut() {
doc.setAttribute('accept', 'image/*')
doc.click()
doc.onchange = async (e) => {
cutImage.value = URL.createObjectURL(e.target.files[0])
cutShow.value = true
backgroundFile = e.target.files[0]
backgroundImage.value = URL.createObjectURL(e.target.files[0])
localUpShow.value = true
}
}
function saveR2domain() {
const settingForm = {r2Domain: r2DomainInput.value}
editSetting(settingForm)
editSetting(settingForm, true)
}
function openResendForm() {
@@ -824,7 +873,7 @@ function editSetting(settingForm, refreshStatus = true) {
thirdEmailShow.value = false
forwardRulesShow.value = false
}).catch((e) => {
console.log(e)
console.error(e)
loginOpacity.value = setting.value.loginOpacity
setting.value = {...setting.value, ...JSON.parse(backup)}
}).finally(() => {
@@ -892,6 +941,8 @@ function editSetting(settingForm, refreshStatus = true) {
flex-direction: column;
}
.settings-card {
background-color: #fff;
border-radius: 8px;
@@ -939,6 +990,14 @@ function editSetting(settingForm, refreshStatus = true) {
}
}
.title-icon.warning {
position: relative;
top: 2px;
color: gray;
cursor: pointer;
margin-left: 2px;
}
.warning {
margin-left: 5px;
color: gray;
@@ -959,6 +1018,11 @@ function editSetting(settingForm, refreshStatus = true) {
justify-content: space-between;
}
.background-url {
width: min(calc(100vw - 70px), 500px);
}
:deep(.el-dialog) {
width: 400px !important;
@media (max-width: 440px) {
@@ -1027,8 +1091,7 @@ function editSetting(settingForm, refreshStatus = true) {
padding-top: 15px;
width: 100%;
display: flex;
justify-content: end;
justify-content: space-between;
.el-button {
width: fit-content;
}

View File

@@ -644,7 +644,6 @@ function setType() {
function resetUserForm() {
userForm.password = null
userForm.userId = 0
userForm.type = 0
}
function search() {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<title></title>
<link rel="icon" href="/assets/favicon-C5dAZutX.svg" type="image/svg+xml">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script type="module" crossorigin src="/assets/index-mj4MUxg3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C5vjKzw7.css">
<script type="module" crossorigin src="/assets/index-DzqHrwam.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CTj27R8v.css">
</head>
<body>
<div id="loading-first">

View File

@@ -8,11 +8,12 @@ import fileUtils from '../utils/file-utils';
import { emailConst, isDel, roleConst, settingConst } from '../const/entity-const';
import emailUtils from '../utils/email-utils';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import roleService from '../service/role-service';
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(utc);
dayjs.extend(timezone);
export async function email(message, env, ctx) {
@@ -50,25 +51,29 @@ export async function email(message, env, ctx) {
if (account && account.email !== env.admin) {
let { banEmail, banEmailType } = await roleService.selectByUserId({ env: env}, account.userId);
let { banEmail, banEmailType, availDomain } = await roleService.selectByUserId({ env: env }, account.userId);
banEmail = banEmail.split(",").filter(item => item !== "")
if(!roleService.hasAvailDomainPerm(availDomain, message.to)) {
return;
}
banEmail = banEmail.split(',').filter(item => item !== '');
for (const item of banEmail) {
if (item.startsWith('*@')) {
const banDomain = emailUtils.getDomain(item.toLowerCase())
const receiveDomain = emailUtils.getDomain(email.from.address.toLowerCase())
const banDomain = emailUtils.getDomain(item.toLowerCase());
const receiveDomain = emailUtils.getDomain(email.from.address.toLowerCase());
if (banDomain === receiveDomain) {
if (banEmailType === roleConst.banEmailType.ALL) return
if (banEmailType === roleConst.banEmailType.ALL) return;
if (banEmailType === roleConst.banEmailType.CONTENT) {
email.html = 'messageRemoved'
email.text = 'messageRemoved'
email.attachments = []
email.html = 'The content has been deleted';
email.text = 'The content has been deleted';
email.attachments = [];
}
}
@@ -77,12 +82,12 @@ export async function email(message, env, ctx) {
if (item.toLowerCase() === email.from.address.toLowerCase()) {
if (banEmailType === roleConst.banEmailType.ALL) return
if (banEmailType === roleConst.banEmailType.ALL) return;
if (banEmailType === roleConst.banEmailType.CONTENT) {
email.html = 'messageRemoved'
email.text = 'messageRemoved'
email.attachments = []
email.html = 'The content has been deleted';
email.text = 'The content has been deleted';
email.attachments = [];
}
}

View File

@@ -7,6 +7,7 @@ export const role = sqliteTable('role', {
description: text('description'),
banEmail: text('ban_email').notNull().default(''),
banEmailType: integer('ban_email_type').notNull().default(0),
availDomain: text('avail_domain').default(''),
sort: integer('sort'),
isDefault: integer('is_default').default(0),
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(),

View File

@@ -54,6 +54,11 @@ const en = {
unauthorized: 'Unauthorized',
bannedSend: 'You are banned from sending emails',
initSuccess: 'Successfully initialized',
noDomainPermAdd: "No permission to add this domain email",
noDomainPermReg: "No permission to register this domain email",
noDomainPermRegKey: "Registration code not valid for this domain",
noDomainPermSend: "No permission to send from this domain email",
JWTMismatch: 'JWT secret mismatch',
perms: {
"邮件": "Email",
"邮件发送": "Send email",

View File

@@ -46,14 +46,19 @@ const zh = {
delDefRole: '默认身份不能删除',
notJsonDomain: '环境变量domain必须是JSON类型',
noOsUpBack: 'r2对象存储未配置不能上传背景',
noOsDomainUpBack: 'r2域名未配置不上传背景',
noOsDomainUpBack: 'r2域名未配置不上传背景',
starNotExistEmail: '星标的邮件不存在',
emptyBotToken: '验证token不能为空',
botVerifyFail: '人机验证失败,请重试',
authExpired: '身份认证失效,请重新登录',
unauthorized: '权限不足',
bannedSend: '你已被禁止发送邮件',
initSuccess: 'Successfully initialized',
initSuccess: '初始化成功',
noDomainPermAdd: '你没有权限添加该域名邮箱',
noDomainPermReg: '你没有权限注册该域名邮箱',
noDomainPermRegKey: '你的注册码没有权限注册该域名邮箱',
noDomainPermSend: '你没有权限使用该域名邮箱发送邮件',
JWTMismatch: 'jwt_secret 不匹配',
perms: {
"邮件": "邮件",
"邮件发送": "邮件发送",

View File

@@ -8,7 +8,7 @@ const init = {
const secret = c.req.param('secret');
if (secret !== c.env.jwt_secret) {
return c.text(t('initSuccess'));
return c.text('jwt_secret 不匹配');
}
await this.intDB(c);
@@ -19,12 +19,17 @@ const init = {
await this.v1_4DB(c);
await this.v1_5DB(c);
await settingService.refresh(c);
return c.text('初始化成功');
return c.text(t('initSuccess'));
},
async v1_5DB(c) {
await c.env.db.prepare(`UPDATE perm SET perm_key = 'sys-email:list' WHERE perm_key = 'all-email:list'`).run();
await c.env.db.prepare(`UPDATE perm SET perm_key = 'sys-email:delete' WHERE perm_key = 'all-email:delete'`).run();
try {
await c.env.db.prepare(`ALTER TABLE role ADD COLUMN avail_domain TEXT NOT NULL DEFAULT ''`).run();
} catch (e) {
console.warn(`跳过字段添加,原因:${e.message}`);
}
},
async v1_4DB(c) {

View File

@@ -126,7 +126,6 @@ app.use('*', async (c, next) => {
const userPaths = permKeyToPaths(permKeys);
const userPermIndex = userPaths.findIndex(item => {
console.log(item)
return path.startsWith(item);
});

View File

@@ -48,9 +48,17 @@ const accountService = {
const userRow = await userService.selectById(c, userId);
const roleRow = await roleService.selectById(c, userRow.type);
if (roleRow.accountCount && userRow.email !== c.env.admin) {
const userAccountCount = await accountService.countUserAccount(c, userId)
if(userAccountCount >= roleRow.accountCount) throw new BizError(t('accountLimit'), 403);
if (userRow.email !== c.env.admin) {
if (roleRow.accountCount > 0) {
const userAccountCount = await accountService.countUserAccount(c, userId)
if(userAccountCount >= roleRow.accountCount) throw new BizError(t('accountLimit'), 403);
}
if(!roleService.hasAvailDomainPerm(roleRow.availDomain, email)) {
throw new BizError(t('noDomainPermAdd'),403)
}
}
if (await settingService.isAddEmailVerify(c)) {

View File

@@ -159,6 +159,7 @@ const emailService = {
}
if (attDataList.length > 0 && !r2Domain) {
throw new BizError(t('noOsDomainSendPic'));
}
@@ -186,6 +187,18 @@ const emailService = {
throw new BizError(t('senderAccountNotExist'));
}
if (accountRow.userId !== userId) {
throw new BizError(t('sendEmailNotCurUser'));
}
if (c.env.admin !== userRow.email) {
if(!roleService.hasAvailDomainPerm(roleRow.availDomain, accountRow.email)) {
throw new BizError(t('noDomainPermSend'),403)
}
}
const domain = emailUtils.getDomain(accountRow.email);
const resendToken = resendTokens[domain];
@@ -194,10 +207,6 @@ const emailService = {
}
if (accountRow.userId !== userId) {
throw new BizError(t('sendEmailNotCurUser'));
}
if (!name) {
name = emailUtils.getName(accountRow.email);
}

View File

@@ -25,7 +25,11 @@ const loginService = {
const { email, password, token, code } = params;
const {regKey, register} = await settingService.query(c)
const {regKey, register, registerVerify} = await settingService.query(c)
if (registerVerify === settingConst.registerVerify.OPEN) {
await turnstileService.verify(c,token)
}
if (register === settingConst.register.CLOSE) {
throw new BizError(t('regDisabled'));
@@ -76,10 +80,6 @@ const loginService = {
throw new BizError(t('isRegAccount'));
}
if (await settingService.isRegisterVerify(c)) {
await turnstileService.verify(c,token)
}
const { salt, hash } = await saltHashUtils.hashPassword(password);
let defType = null
@@ -89,6 +89,21 @@ const loginService = {
defType = roleRow.roleId
}
const roleRow = await roleService.selectById(c, type || defType);
if(!roleService.hasAvailDomainPerm(roleRow.availDomain, email)) {
if (type) {
throw new BizError(t('noDomainPermRegKey'),403)
}
if (defType) {
throw new BizError(t('noDomainPermReg'),403)
}
}
const userId = await userService.insert(c, { email, regKeyId,password: hash, salt, type: type || defType });
await userService.updateUserInfo(c, userId, true);

View File

@@ -9,12 +9,13 @@ import userService from './user-service';
import user from '../entity/user';
import verifyUtils from '../utils/verify-utils';
import { t } from '../i18n/i18n.js';
import emailUtils from '../utils/email-utils';
const roleService = {
async add(c, params, userId) {
let { name, permIds, banEmail } = params;
let { name, permIds, banEmail, availDomain } = params;
if (!name) {
throw new BizError(t('emptyRoleName'));
@@ -32,9 +33,11 @@ const roleService = {
throw new BizError(t('notEmail'));
}
banEmail = banEmail.join(',')
banEmail = banEmail.join(',');
roleRow = await orm(c).insert(role).values({...params, banEmail, userId}).returning().get();
availDomain = availDomain.join(',');
roleRow = await orm(c).insert(role).values({...params, banEmail, availDomain, userId}).returning().get();
if (permIds.length === 0) {
return;
@@ -55,7 +58,8 @@ const roleService = {
.where(eq(perm.type, permConst.type.BUTTON)).all();
roleList.forEach(role => {
role.banEmail = role.banEmail.split(",").filter(item => item !== "")
role.banEmail = role.banEmail.split(",").filter(item => item !== "");
role.availDomain = role.availDomain.split(",").filter(item => item !== "");
role.permIds = permList.filter(perm => perm.roleId === role.roleId).map(perm => perm.permId);
});
@@ -64,7 +68,7 @@ const roleService = {
async setRole(c, params) {
let { name, permIds, roleId, banEmail } = params;
let { name, permIds, roleId, banEmail, availDomain } = params;
if (!name) {
throw new BizError(t('emptyRoleName'));
@@ -80,7 +84,9 @@ const roleService = {
banEmail = banEmail.join(',')
await orm(c).update(role).set({...params, banEmail}).where(eq(role.roleId, roleId)).run();
availDomain = availDomain.join(',')
await orm(c).update(role).set({...params, banEmail, availDomain}).where(eq(role.roleId, roleId)).run();
await orm(c).delete(rolePerm).where(eq(rolePerm.roleId, roleId)).run();
if (permIds.length > 0) {
@@ -150,6 +156,24 @@ const roleService = {
selectByUserId(c, userId) {
return orm(c).select(role).from(user).leftJoin(role, eq(role.roleId, user.type)).where(eq(user.userId, userId)).get();
},
hasAvailDomainPerm(availDomain, email) {
availDomain = availDomain.split(',').filter(item => item !== '');
if (availDomain.length === 0) {
return true
}
const availIndex = availDomain.findIndex(item => {
const domain = emailUtils.getDomain(email.toLowerCase());
const availDomainItem = emailUtils.getDomain(item.toLowerCase());
console.log(domain,availDomainItem)
return domain === availDomainItem
})
return availIndex > -1
}
};

View File

@@ -69,30 +69,41 @@ const settingService = {
const settingRow = await this.query(c);
let { background } = params
if (!background.startsWith('http')) {
if (!c.env.r2) {
throw new BizError(t('noOsUpBack'));
}
if (!settingRow.r2Domain) {
throw new BizError(t('noOsDomainUpBack'));
}
const file = fileUtils.base64ToFile(background)
const arrayBuffer = await file.arrayBuffer();
background = constant.BACKGROUND_PREFIX + await fileUtils.getBuffHash(arrayBuffer) + fileUtils.getExtFileName(file.name);
await r2Service.putObj(c, background, arrayBuffer, {
contentType: file.type
});
if (!c.env.r2) {
throw new BizError(t('noOsUpBack'));
}
if (!settingRow.r2Domain) {
throw new BizError(t('noOsDomainUpBack'));
}
const { background } = params;
const file = fileUtils.base64ToFile(background);
const arrayBuffer = await file.arrayBuffer();
const key = constant.BACKGROUND_PREFIX + await fileUtils.getBuffHash(arrayBuffer) + fileUtils.getExtFileName(file.name);
await r2Service.putObj(c, key, file, {
contentType: file.type
});
if (settingRow.background) {
await r2Service.delete(c, settingRow.background);
try {
await r2Service.delete(c, settingRow.background);
} catch (e) {
console.error(e)
}
}
await orm(c).update(setting).set({ background: key }).run();
await orm(c).update(setting).set({ background }).run();
await this.refresh(c);
return key;
return background;
},
async physicsDeleteAll(c) {

View File

@@ -27,7 +27,7 @@ const fileUtils = {
* @returns {File} File 对象
*/
base64ToFile(base64Data, customFilename) {
const match = base64Data.match(/^data:(image|video)\/([a-zA-Z0-9.+-]+);base64,/);
const match = base64Data.match(/^data:(image|jpeg|video)\/([a-zA-Z0-9.+-]+);base64,/);
if (!match) {
throw new Error('Invalid base64 data format');
}
@@ -35,7 +35,7 @@ const fileUtils = {
const type = match[1]; // image 或 video
const ext = match[2]; // jpg, png, mp4 等
const mimeType = `${type}/${ext}`;
const cleanBase64 = base64Data.replace(/^data:(image|video)\/[a-zA-Z0-9.+-]+;base64,/, '');
const cleanBase64 = base64Data.replace(/^data:(image|jpeg|video)\/[a-zA-Z0-9.+-]+;base64,/, '');
const byteCharacters = atob(cleanBase64);
const byteArrays = [];

View File

@@ -32,7 +32,7 @@ run_worker_first = true
crons = ["0 16 * * *"]
[vars]
orm_log = true
domain = ["example.com", "example2.com"]
orm_log = false
domain = ["example.com", "example2.com", "example3.com", "example4.com"]
admin = "admin@example.com"
jwt_secret = "b7f29a1d-18e2-4d3b-941f-f6b2c97c02fd"