mirror of
https://github.com/beilunyang/moemail.git
synced 2025-12-24 11:30:51 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d49790a7 | ||
|
|
6ddd5bdf4e | ||
|
|
e8e2349a97 | ||
|
|
fd46bf2661 | ||
|
|
21d09a2cb0 | ||
|
|
eb8023280b | ||
|
|
58e6d06bed | ||
|
|
436666a88b | ||
|
|
dae8122231 | ||
|
|
7210c68fbd | ||
|
|
994ab8acc3 | ||
|
|
c405c02a34 | ||
|
|
d9fb486104 | ||
|
|
77cb52e608 | ||
|
|
16bc357973 | ||
|
|
b75d9ada43 | ||
|
|
da979d2a51 | ||
|
|
200d82f874 | ||
|
|
ed8885a2d8 | ||
|
|
cd429b96d8 | ||
|
|
5173cbf9d3 | ||
|
|
9b7ed0b031 | ||
|
|
ea7fd5490c | ||
|
|
b1d898e298 | ||
|
|
f86d944c25 | ||
|
|
59671091b6 | ||
|
|
19d805de57 | ||
|
|
2566a8a105 | ||
|
|
821a32aa4b |
@@ -1,3 +1,10 @@
|
||||
AUTH_GITHUB_ID = ""
|
||||
AUTH_GITHUB_SECRET = ""
|
||||
AUTH_SECRET = ""
|
||||
AUTH_SECRET = ""
|
||||
|
||||
CLOUDFLARE_API_TOKEN = ""
|
||||
CLOUDFLARE_ACCOUNT_ID = ""
|
||||
DATABASE_NAME = ""
|
||||
KV_NAMESPACE_NAME = ""
|
||||
|
||||
CUSTOM_DOMAIN = ""
|
||||
143
.github/workflows/deploy.yml
vendored
143
.github/workflows/deploy.yml
vendored
@@ -5,22 +5,6 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_migrations:
|
||||
description: 'Run database migrations'
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
deploy_email_worker:
|
||||
description: 'Deploy email Worker'
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
deploy_cleanup_worker:
|
||||
description: 'Deploy cleanup Worker'
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -51,112 +35,23 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Check if database migrations have changes
|
||||
- name: Check migrations changes
|
||||
id: check_migrations
|
||||
if: github.event_name == 'push'
|
||||
- name: Run deploy script
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
PROJECT_NAME: ${{ secrets.PROJECT_NAME }}
|
||||
DATABASE_NAME: ${{ secrets.DATABASE_NAME }}
|
||||
DATABASE_ID: ${{ secrets.DATABASE_ID }}
|
||||
KV_NAMESPACE_NAME: ${{ secrets.KV_NAMESPACE_NAME }}
|
||||
KV_NAMESPACE_ID: ${{ secrets.KV_NAMESPACE_ID }}
|
||||
CUSTOM_DOMAIN: ${{ secrets.CUSTOM_DOMAIN }}
|
||||
AUTH_GITHUB_ID: ${{ secrets.AUTH_GITHUB_ID }}
|
||||
AUTH_GITHUB_SECRET: ${{ secrets.AUTH_GITHUB_SECRET }}
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
run: pnpm dlx tsx scripts/deploy/index.ts
|
||||
|
||||
# Clean up
|
||||
- name: Post deployment cleanup
|
||||
run: |
|
||||
if [ -z "${{ steps.previoustag.outputs.tag }}" ]; then
|
||||
echo "migrations_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q "^drizzle/"; then
|
||||
echo "migrations_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "migrations_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
# Process configuration files
|
||||
- name: Process configuration files
|
||||
run: |
|
||||
# Process wrangler.example.toml
|
||||
if [ -f wrangler.example.toml ]; then
|
||||
cp wrangler.example.toml wrangler.toml
|
||||
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.toml
|
||||
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.toml
|
||||
sed -i "s/id = \"\"/id = \"${{ secrets.KV_NAMESPACE_ID }}\"/" wrangler.toml
|
||||
fi
|
||||
|
||||
# Process wrangler.email.example.toml
|
||||
if [ -f wrangler.email.example.toml ]; then
|
||||
cp wrangler.email.example.toml wrangler.email.toml
|
||||
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.email.toml
|
||||
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.email.toml
|
||||
fi
|
||||
|
||||
# Process wrangler.cleanup.example.toml
|
||||
if [ -f wrangler.cleanup.example.toml ]; then
|
||||
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
|
||||
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.cleanup.toml
|
||||
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.cleanup.toml
|
||||
fi
|
||||
|
||||
# Run database migrations if needed
|
||||
- name: Run database migrations
|
||||
if: |
|
||||
github.event_name == 'push' && steps.check_migrations.outputs.migrations_changed == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' && github.event.inputs.run_migrations == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: pnpm db:migrate-remote
|
||||
|
||||
# Check if workers have changes
|
||||
- name: Check workers changes
|
||||
id: check_changes
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
if [ -z "${{ steps.previoustag.outputs.tag }}" ]; then
|
||||
# Check email worker and its dependencies
|
||||
if git ls-files | grep -q -E "workers/email-receiver.ts|app/lib/schema.ts|app/lib/webhook.ts|app/config/webhook.ts"; then
|
||||
echo "email_worker_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "email_worker_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
# Check cleanup worker
|
||||
if git ls-files | grep -q "workers/cleanup.ts"; then
|
||||
echo "cleanup_worker_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "cleanup_worker_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
# Check email worker and its dependencies changes
|
||||
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q -E "workers/email-receiver.ts|app/lib/schema.ts|app/lib/webhook.ts|app/config/webhook.ts"; then
|
||||
echo "email_worker_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "email_worker_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
# Check cleanup worker changes
|
||||
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q "workers/cleanup.ts"; then
|
||||
echo "cleanup_worker_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "cleanup_worker_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deploy Pages application
|
||||
- name: Deploy Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: pnpm run deploy:pages
|
||||
|
||||
# Deploy email worker if changed or manually triggered
|
||||
- name: Deploy Email Worker
|
||||
if: |
|
||||
github.event_name == 'push' && steps.check_changes.outputs.email_worker_changed == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_email_worker == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: pnpm run deploy:email
|
||||
|
||||
# Deploy cleanup worker if changed or manually triggered
|
||||
- name: Deploy Cleanup Worker
|
||||
if: |
|
||||
github.event_name == 'push' && steps.check_changes.outputs.cleanup_worker_changed == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_cleanup_worker == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: pnpm run deploy:cleanup
|
||||
rm -f .env*.*
|
||||
rm -f wrangler*.json
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,4 +46,8 @@ wrangler.email.toml
|
||||
wrangler.cleanup.toml
|
||||
|
||||
public/workbox-*.js
|
||||
public/sw.js
|
||||
public/sw.js
|
||||
|
||||
wrangler.json
|
||||
wrangler.cleanup.json
|
||||
wrangler.email.json
|
||||
135
README.md
135
README.md
@@ -15,6 +15,7 @@
|
||||
<a href="#部署">部署</a> •
|
||||
<a href="#邮箱域名配置">邮箱域名配置</a> •
|
||||
<a href="#权限系统">权限系统</a> •
|
||||
<a href="#系统设置">系统设置</a> •
|
||||
<a href="#Webhook 集成">Webhook 集成</a> •
|
||||
<a href="#OpenAPI">OpenAPI</a> •
|
||||
<a href="#环境变量">环境变量</a> •
|
||||
@@ -86,9 +87,9 @@ pnpm install
|
||||
|
||||
3. 设置 wrangler:
|
||||
```bash
|
||||
cp wrangler.example.toml wrangler.toml
|
||||
cp wrangler.email.example.toml wrangler.email.toml
|
||||
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
|
||||
cp wrangler.example.json wrangler.json
|
||||
cp wrangler.email.example.json wrangler.email.json
|
||||
cp wrangler.cleanup.example.json wrangler.cleanup.json
|
||||
```
|
||||
设置 Cloudflare D1 数据库名以及数据库 ID
|
||||
|
||||
@@ -128,67 +129,16 @@ pnpm generate-test-data
|
||||
```
|
||||
## 部署
|
||||
|
||||
### 视频版部署教程
|
||||
https://www.bilibili.com/video/BV19wrXY2ESM/
|
||||
|
||||
### 部署前准备
|
||||
|
||||
在开始部署之前,需要在 Cloudflare 控制台完成以下准备工作:
|
||||
|
||||
1. **创建 D1 数据库**
|
||||
- 登录 [Cloudflare 控制台](https://dash.cloudflare.com/)
|
||||
- 选择 “存储与数据库” -> “D1 SQL 数据库”
|
||||
- 创建一个数据库(例如:moemail)
|
||||
- 记录下数据库名称和数据库 ID,后续配置需要用到
|
||||
|
||||
2. **创建 KV 命名空间**
|
||||
- 登录 [Cloudflare 控制台](https://dash.cloudflare.com/)
|
||||
- 选择 “存储与数据库” -> “KV”
|
||||
- 创建一个 KV 命名空间(例如:moemail)
|
||||
- 记录下命名空间 ID,后续配置需要用到
|
||||
|
||||
3. **创建 Pages 项目**
|
||||
- 登录 [Cloudflare 控制台](https://dash.cloudflare.com/)
|
||||
- 选择 “Workers 和 Pages”
|
||||
- 点击 “创建” 并选择 “Pages” 标签
|
||||
- 选择 “使用直接上传创建”
|
||||
- 点击 “上传资产”
|
||||
- 输入项目名称
|
||||
- 注意:项目名称必须为 moemail,否则无法正常部署
|
||||
- 输入项目名称后,点击 “创建项目” 即可,不需要上传任何文件以及点击“部署站点”,之后我们会通过 本地运行Wrangler 或者通过 Github Actions 自动部署
|
||||
4. **为 Pages 项目添加 AUTH 认证相关的 SECRETS**
|
||||
- 在 Overview 中选择刚刚创建的 Pages 项目
|
||||
- 在 Settings 中选择变量和机密
|
||||
- 添加 AUTH_GITHUB_ID, AUTH_GITHUB_SECRET, AUTH_SECRET
|
||||
|
||||
### 本地 Wrangler 部署
|
||||
|
||||
1. 设置 wrangler:
|
||||
1. 创建 .env 文件
|
||||
```bash
|
||||
cp wrangler.example.toml wrangler.toml
|
||||
cp wrangler.email.example.toml wrangler.email.toml
|
||||
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
|
||||
cp .env.example .env
|
||||
```
|
||||
设置 Cloudflare D1 数据库名以及数据库 ID
|
||||
2. 在 .env 文件中设置[环境变量](#环境变量)
|
||||
|
||||
2. 创建云端 D1 数据库表结构
|
||||
3. 运行部署脚本
|
||||
```bash
|
||||
pnpm db:migrate-remote
|
||||
```
|
||||
|
||||
2. 部署主应用到 Cloudflare Pages:
|
||||
```bash
|
||||
pnpm deploy:pages
|
||||
```
|
||||
|
||||
3. 部署邮件 worker:
|
||||
```bash
|
||||
pnpm deploy:email
|
||||
```
|
||||
|
||||
4. 部署清理 worker:
|
||||
```bash
|
||||
pnpm deploy:cleanup
|
||||
pnpm dlx tsx ./scripts/deploy/index.ts
|
||||
```
|
||||
|
||||
### Github Actions 部署
|
||||
@@ -196,10 +146,7 @@ pnpm deploy:cleanup
|
||||
本项目可使用 GitHub Actions 实现自动化部署。支持以下触发方式:
|
||||
|
||||
1. **自动触发**:推送新的 tag 时自动触发部署流程
|
||||
2. **手动触发**:在 GitHub Actions 页面手动触发,可选择以下部署选项:
|
||||
- Run database migrations:执行数据库迁移
|
||||
- Deploy email Worker:重新部署邮件 Worker
|
||||
- Deploy cleanup Worker:重新部署清理 Worker
|
||||
2. **手动触发**:在 GitHub Actions 页面手动触发
|
||||
|
||||
#### 部署步骤
|
||||
|
||||
@@ -207,8 +154,11 @@ pnpm deploy:cleanup
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API 令牌
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID
|
||||
- `DATABASE_NAME`: D1 数据库名称
|
||||
- `DATABASE_ID`: D1 数据库 ID
|
||||
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置
|
||||
- `KV_NAMESPACE_NAME`: Cloudflare KV namespace 名称,用于存储网站配置
|
||||
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
|
||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||
- `AUTH_SECRET`: NextAuth Secret,用来加密 session,请设置一个随机字符串
|
||||
- `CUSTOM_DOMAIN`: 网站自定义域名,用于访问 MoeMail (可选, 如果不填, 则会使用 Cloudflare Pages 默认域名)
|
||||
|
||||
2. 选择触发方式:
|
||||
|
||||
@@ -225,23 +175,12 @@ pnpm deploy:cleanup
|
||||
- 进入仓库的 Actions 页面
|
||||
- 选择 "Deploy" workflow
|
||||
- 点击 "Run workflow"
|
||||
- 选择需要执行的部署选项
|
||||
- 点击 "Run workflow" 开始部署
|
||||
|
||||
3. GitHub Actions 会自动执行以下任务:
|
||||
- 构建并部署主应用到 Cloudflare Pages
|
||||
- 根据选项或文件变更执行数据库迁移
|
||||
- 根据选项或文件变更部署 Email Worker
|
||||
- 根据选项或文件变更部署 Cleanup Worker
|
||||
|
||||
4. 部署进度可以在仓库的 Actions 标签页查看
|
||||
3. 部署进度可以在仓库的 Actions 标签页查看
|
||||
|
||||
#### 注意事项
|
||||
- 确保所有 Secrets 都已正确设置
|
||||
- 使用 tag 触发时,tag 必须以 `v` 开头(例如:v1.0.0)
|
||||
- 使用 tag 触发时,只有文件发生变更的部分会被部署
|
||||
- 手动触发时,可以选择性地执行特定的部署任务
|
||||
- 每次部署都会重新部署主应用
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/moemail)
|
||||
|
||||
@@ -273,6 +212,7 @@ pnpm deploy:cleanup
|
||||
### 注意事项
|
||||
- 确保域名的 DNS 托管在 Cloudflare
|
||||
- Email Worker 必须已经部署成功
|
||||
- 如果 Catch-All 状态不可用(一直 loading),请点击`路由规则`旁边的`目标地址`, 进去绑定一个邮箱
|
||||
|
||||
## 权限系统
|
||||
|
||||
@@ -327,8 +267,18 @@ pnpm deploy:cleanup
|
||||
- **Webhook 管理**:配置邮件通知的 Webhook
|
||||
- **API Key 管理**:创建和管理 API 访问密钥
|
||||
- **用户管理**:升降用户角色
|
||||
- **系统配置**:管理系统全局设置
|
||||
- **系统设置**:管理系统全局设置
|
||||
|
||||
## 系统设置
|
||||
|
||||
系统设置存储在 Cloudflare KV 中,包括以下内容:
|
||||
|
||||
- `DEFAULT_ROLE`: 新注册用户默认角色,可选值为 `CIVILIAN`、`KNIGHT`、`DUKE`
|
||||
- `EMAIL_DOMAINS`: 支持的邮箱域名,多个域名用逗号分隔
|
||||
- `ADMIN_CONTACT`: 管理员联系方式
|
||||
- `MAX_EMAILS`: 每个用户可创建的最大邮箱数量
|
||||
|
||||
**皇帝**角色可以在个人中心页面设置
|
||||
|
||||
## Webhook 集成
|
||||
|
||||
@@ -470,8 +420,10 @@ const data = await res.json();
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare Account ID
|
||||
- `DATABASE_NAME`: D1 数据库名称
|
||||
- `DATABASE_ID`: D1 数据库 ID
|
||||
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置
|
||||
- `DATABASE_ID`: D1 数据库 ID (可选, 如果不填, 则会自动通过 Cloudflare API 获取)
|
||||
- `KV_NAMESPACE_NAME`: Cloudflare KV namespace 名称,用于存储网站配置
|
||||
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置 (可选, 如果不填, 则会自动通过 Cloudflare API 获取)
|
||||
- `CUSTOM_DOMAIN`: 网站自定义域名, 如:moemail.app (可选, 如果不填, 则会使用 Cloudflare Pages 默认域名)
|
||||
|
||||
## Github OAuth App 配置
|
||||
|
||||
@@ -490,10 +442,25 @@ const data = await res.json();
|
||||
|
||||
本项目采用 [MIT](LICENSE) 许可证
|
||||
|
||||
## 交流群
|
||||
<img src="https://pic.otaku.ren/20250210/AQADOMUxG7BRUFV-.jpg" style="width: 400px;"/>
|
||||
<br />
|
||||
如二维码失效,请添加我的个人微信(hansenones),并备注 "MoeMail" 加入微信交流群
|
||||
## 交流
|
||||
<table>
|
||||
<tr style="max-width: 360px">
|
||||
<td>
|
||||
<img src="https://pic.otaku.ren/20250309/AQADAcQxGxQjaVZ-.jpg" />
|
||||
</td>
|
||||
<td>
|
||||
<img src="https://pic.otaku.ren/20250309/AQADCMQxGxQjaVZ-.jpg" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="max-width: 360px">
|
||||
<td>
|
||||
关注公众号,了解更多项目进展以及AI,区块链,独立开发资讯
|
||||
</td>
|
||||
<td>
|
||||
添加微信,备注 "MoeMail" 拉你进微信交流群
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 支持
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
const env = getRequestContext().env
|
||||
const adminContact = await env.SITE_CONFIG.get("ADMIN_CONTACT")
|
||||
|
||||
return Response.json({
|
||||
adminContact: adminContact || ""
|
||||
})
|
||||
}
|
||||
@@ -1,28 +1,32 @@
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
const env = getRequestContext().env
|
||||
const [defaultRole, emailDomains, adminContact] = await Promise.all([
|
||||
const [defaultRole, emailDomains, adminContact, maxEmails] = await Promise.all([
|
||||
env.SITE_CONFIG.get("DEFAULT_ROLE"),
|
||||
env.SITE_CONFIG.get("EMAIL_DOMAINS"),
|
||||
env.SITE_CONFIG.get("ADMIN_CONTACT")
|
||||
env.SITE_CONFIG.get("ADMIN_CONTACT"),
|
||||
env.SITE_CONFIG.get("MAX_EMAILS")
|
||||
])
|
||||
|
||||
return Response.json({
|
||||
defaultRole: defaultRole || ROLES.CIVILIAN,
|
||||
emailDomains: emailDomains || "",
|
||||
adminContact: adminContact || ""
|
||||
emailDomains: emailDomains || "moemail.app",
|
||||
adminContact: adminContact || "",
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { defaultRole, emailDomains, adminContact } = await request.json() as {
|
||||
const { defaultRole, emailDomains, adminContact, maxEmails } = await request.json() as {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||
emailDomains: string,
|
||||
adminContact: string
|
||||
adminContact: string,
|
||||
maxEmails: string
|
||||
}
|
||||
|
||||
if (![ROLES.DUKE, ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
|
||||
@@ -33,7 +37,8 @@ export async function POST(request: Request) {
|
||||
await Promise.all([
|
||||
env.SITE_CONFIG.put("DEFAULT_ROLE", defaultRole),
|
||||
env.SITE_CONFIG.put("EMAIL_DOMAINS", emailDomains),
|
||||
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact)
|
||||
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact),
|
||||
env.SITE_CONFIG.put("MAX_EMAILS", maxEmails)
|
||||
])
|
||||
|
||||
return Response.json({ success: true })
|
||||
|
||||
@@ -5,6 +5,56 @@ import { and, eq } from "drizzle-orm"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; messageId: string }> }
|
||||
) {
|
||||
const userId = await getUserId()
|
||||
|
||||
try {
|
||||
const db = createDb()
|
||||
const { id, messageId } = await params
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(
|
||||
eq(emails.id, id),
|
||||
eq(emails.userId, userId!)
|
||||
)
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email not found or no permission to view" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const message = await db.query.messages.findFirst({
|
||||
where: and(
|
||||
eq(messages.emailId, id),
|
||||
eq(messages.id, messageId)
|
||||
)
|
||||
})
|
||||
|
||||
if(!message) {
|
||||
return NextResponse.json(
|
||||
{ error: "Message not found or already deleted" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.delete(messages)
|
||||
.where(eq(messages.id, messageId))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to delete email:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete message" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string; messageId: string }> }) {
|
||||
try {
|
||||
const { id, messageId } = await params
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const domainString = await getRequestContext().env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
|
||||
return NextResponse.json({ domains: domainString ? domainString.split(',') : ["moemail.app"] })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch domains:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取域名列表失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,29 +7,37 @@ import { EXPIRY_OPTIONS } from "@/types/email"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
import { getUserRole } from "@/lib/auth"
|
||||
import { ROLES } from "@/lib/permissions"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const db = createDb()
|
||||
const env = getRequestContext().env
|
||||
|
||||
const userId = await getUserId()
|
||||
const userRole = await getUserRole(userId!)
|
||||
|
||||
try {
|
||||
const activeEmailsCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emails)
|
||||
.where(
|
||||
and(
|
||||
eq(emails.userId, userId!),
|
||||
gt(emails.expiresAt, new Date())
|
||||
if (userRole !== ROLES.EMPEROR) {
|
||||
const maxEmails = await env.SITE_CONFIG.get("MAX_EMAILS") || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
|
||||
const activeEmailsCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emails)
|
||||
.where(
|
||||
and(
|
||||
eq(emails.userId, userId!),
|
||||
gt(emails.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (Number(activeEmailsCount[0].count) >= EMAIL_CONFIG.MAX_ACTIVE_EMAILS) {
|
||||
return NextResponse.json(
|
||||
{ error: `已达到最大邮箱数量限制 (${EMAIL_CONFIG.MAX_ACTIVE_EMAILS})` },
|
||||
{ status: 403 }
|
||||
)
|
||||
|
||||
if (Number(activeEmailsCount[0].count) >= Number(maxEmails)) {
|
||||
return NextResponse.json(
|
||||
{ error: `已达到最大邮箱数量限制 (${maxEmails})` },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { name, expiryTime, domain } = await request.json<{
|
||||
@@ -45,7 +53,7 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const domainString = await getRequestContext().env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
const domainString = await env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
const domains = domainString ? domainString.split(',') : ["moemail.app"]
|
||||
|
||||
if (!domains || !domains.includes(domain)) {
|
||||
|
||||
@@ -12,16 +12,17 @@ import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
interface CreateDialogProps {
|
||||
onEmailCreated: () => void
|
||||
}
|
||||
|
||||
export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
const { config } = useConfig()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [emailName, setEmailName] = useState("")
|
||||
const [domains, setDomains] = useState<string[]>([])
|
||||
const [currentDomain, setCurrentDomain] = useState("")
|
||||
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
|
||||
const { toast } = useToast()
|
||||
@@ -83,16 +84,11 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDomains = async () => {
|
||||
const response = await fetch("/api/emails/domains");
|
||||
const data = (await response.json()) as { domains: string[] };
|
||||
setDomains(data.domains || []);
|
||||
setCurrentDomain(data.domains[0] || "");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDomains()
|
||||
}, [])
|
||||
if ((config?.emailDomainsArray?.length ?? 0) > 0) {
|
||||
setCurrentDomain(config?.emailDomainsArray[0] ?? "")
|
||||
}
|
||||
}, [config])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -114,13 +110,13 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
placeholder="输入邮箱名"
|
||||
className="flex-1"
|
||||
/>
|
||||
{domains.length > 1 && (
|
||||
{(config?.emailDomainsArray?.length ?? 0) > 1 && (
|
||||
<Select value={currentDomain} onValueChange={setCurrentDomain}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domains.map(d => (
|
||||
{config?.emailDomainsArray?.map(d => (
|
||||
<SelectItem key={d} value={d}>@{d}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { ROLES } from "@/lib/permissions"
|
||||
import { useUserRole } from "@/hooks/use-user-role"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
interface Email {
|
||||
id: string
|
||||
@@ -40,6 +43,8 @@ interface EmailResponse {
|
||||
|
||||
export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
const { data: session } = useSession()
|
||||
const { config } = useConfig()
|
||||
const { role } = useUserRole()
|
||||
const [emails, setEmails] = useState<Email[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
@@ -109,7 +114,6 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (session) fetchEmails()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session])
|
||||
|
||||
const handleDelete = async (email: Email) => {
|
||||
@@ -167,7 +171,11 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱
|
||||
{role === ROLES.EMPEROR ? (
|
||||
`${total}/∞ 个邮箱`
|
||||
) : (
|
||||
`${total}/${config?.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<CreateDialog onEmailCreated={handleRefresh} />
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Mail, Calendar, RefreshCw } from "lucide-react"
|
||||
import {Mail, Calendar, RefreshCw, Trash2} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useThrottle } from "@/hooks/use-throttle"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
@@ -19,7 +30,7 @@ interface MessageListProps {
|
||||
id: string
|
||||
address: string
|
||||
}
|
||||
onMessageSelect: (messageId: string) => void
|
||||
onMessageSelect: (messageId: string | null) => void
|
||||
selectedMessageId?: string | null
|
||||
}
|
||||
|
||||
@@ -38,6 +49,8 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
const pollTimeoutRef = useRef<Timer>()
|
||||
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
|
||||
const [total, setTotal] = useState(0)
|
||||
const [messageToDelete, setMessageToDelete] = useState<Message | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 当 messages 改变时更新 ref
|
||||
useEffect(() => {
|
||||
@@ -118,6 +131,44 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
}
|
||||
}, 200)
|
||||
|
||||
const handleDelete = async (message: Message) => {
|
||||
try {
|
||||
const response = await fetch(`/api/emails/${email.id}/${message.id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: "错误",
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setMessages(prev => prev.filter(e => e.id !== message.id))
|
||||
setTotal(prev => prev - 1)
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "邮件已删除"
|
||||
})
|
||||
|
||||
if (selectedMessageId === message.id) {
|
||||
onMessageSelect(null)
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除邮件失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setMessageToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!email.id) {
|
||||
return
|
||||
@@ -134,6 +185,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
}, [email.id])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-2 flex justify-between items-center border-b border-primary/20">
|
||||
<Button
|
||||
@@ -160,7 +212,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
key={message.id}
|
||||
onClick={() => onMessageSelect(message.id)}
|
||||
className={cn(
|
||||
"p-3 hover:bg-primary/5 cursor-pointer",
|
||||
"p-3 hover:bg-primary/5 cursor-pointer group",
|
||||
selectedMessageId === message.id && "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
@@ -176,6 +228,17 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="opacity-0 group-hover:opacity-100 h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMessageToDelete(message)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -192,5 +255,25 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog open={!!messageToDelete} onOpenChange={() => setMessageToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除邮件 {messageToDelete?.subject} 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
onClick={() => messageToDelete && handleDelete(messageToDelete)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
@@ -27,6 +28,7 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("html")
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMessage = async () => {
|
||||
@@ -47,8 +49,7 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
fetchMessage()
|
||||
}, [emailId, messageId])
|
||||
|
||||
// 处理 iframe 内容
|
||||
useEffect(() => {
|
||||
const updateIframeContent = () => {
|
||||
if (viewMode === "html" && message?.html && iframeRef.current) {
|
||||
const iframe = iframeRef.current
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document
|
||||
@@ -66,8 +67,8 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: ${document.documentElement.classList.contains('dark') ? '#fff' : '#000'};
|
||||
background: transparent;
|
||||
color: ${theme === 'dark' ? '#fff' : '#000'};
|
||||
background: ${theme === 'dark' ? '#1a1a1a' : '#fff'};
|
||||
}
|
||||
body {
|
||||
padding: 20px;
|
||||
@@ -88,21 +89,21 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: ${document.documentElement.classList.contains('dark')
|
||||
? 'rgba(130, 109, 217, 0.3)'
|
||||
background: ${theme === 'dark'
|
||||
? 'rgba(130, 109, 217, 0.3)'
|
||||
: 'rgba(130, 109, 217, 0.2)'};
|
||||
border-radius: 9999px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: ${document.documentElement.classList.contains('dark')
|
||||
background: ${theme === 'dark'
|
||||
? 'rgba(130, 109, 217, 0.5)'
|
||||
: 'rgba(130, 109, 217, 0.4)'};
|
||||
}
|
||||
/* Firefox 滚动条 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${document.documentElement.classList.contains('dark')
|
||||
scrollbar-color: ${theme === 'dark'
|
||||
? 'rgba(130, 109, 217, 0.3) transparent'
|
||||
: 'rgba(130, 109, 217, 0.2) transparent'};
|
||||
}
|
||||
@@ -139,7 +140,12 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [message?.html, viewMode])
|
||||
}
|
||||
|
||||
// 监听主题变化和内容变化
|
||||
useEffect(() => {
|
||||
updateIframeContent()
|
||||
}, [message?.html, viewMode, theme])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -45,7 +45,10 @@ export function ThreeColumnLayout() {
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailList
|
||||
onEmailSelect={setSelectedEmail}
|
||||
onEmailSelect={(email) => {
|
||||
setSelectedEmail(email)
|
||||
setSelectedMessageId(null)
|
||||
}}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
39
app/components/float-menu.tsx
Normal file
39
app/components/float-menu.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { Github } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
export function FloatMenu() {
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20"
|
||||
onClick={() => window.open("https://github.com/beilunyang/moemail", "_blank")}
|
||||
>
|
||||
<Github
|
||||
className="w-4 h-4 transition-all duration-300 text-primary group-hover:scale-110"
|
||||
/>
|
||||
<span className="sr-only">获取网站源代码</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<p>获取网站源代码</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAdminContact } from "@/hooks/use-admin-contact"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
export function NoPermissionDialog() {
|
||||
const router = useRouter()
|
||||
const { adminContact } = useAdminContact()
|
||||
const { config } = useConfig()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background/50 backdrop-blur-sm z-50">
|
||||
@@ -15,8 +15,8 @@ export function NoPermissionDialog() {
|
||||
<h1 className="text-xl md:text-2xl font-bold">权限不足</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">你没有权限访问此页面,请联系网站管理员</p>
|
||||
{
|
||||
adminContact && (
|
||||
<p className="text-sm md:text-base text-muted-foreground">管理员联系方式:{adminContact}</p>
|
||||
config?.adminContact && (
|
||||
<p className="text-sm md:text-base text-muted-foreground">管理员联系方式:{config.adminContact}</p>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Label } from "@/components/ui/label"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import { useRolePermission } from "@/hooks/use-role-permission"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { useAdminContact } from "@/hooks/use-admin-contact"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
type ApiKey = {
|
||||
id: string
|
||||
@@ -68,7 +68,7 @@ export function ApiKeyPanel() {
|
||||
}
|
||||
}, [canManageApiKey])
|
||||
|
||||
const { adminContact } = useAdminContact()
|
||||
const { config } = useConfig()
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) return
|
||||
@@ -248,8 +248,8 @@ export function ApiKeyPanel() {
|
||||
<p>需要公爵或更高权限才能管理 API Key</p>
|
||||
<p className="mt-2">请联系网站管理员升级您的角色</p>
|
||||
{
|
||||
adminContact && (
|
||||
<p className="mt-2">管理员联系方式:{adminContact}</p>
|
||||
config?.adminContact && (
|
||||
<p className="mt-2">管理员联系方式:{config.adminContact}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export function ConfigPanel() {
|
||||
const [defaultRole, setDefaultRole] = useState<string>("")
|
||||
const [emailDomains, setEmailDomains] = useState<string>("")
|
||||
const [adminContact, setAdminContact] = useState<string>("")
|
||||
const [maxEmails, setMaxEmails] = useState<string>(EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -31,10 +33,14 @@ export function ConfigPanel() {
|
||||
if (res.ok) {
|
||||
const data = await res.json() as {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||
emailDomains: string
|
||||
emailDomains: string,
|
||||
adminContact: string,
|
||||
maxEmails: string
|
||||
}
|
||||
setDefaultRole(data.defaultRole)
|
||||
setEmailDomains(data.emailDomains)
|
||||
setAdminContact(data.adminContact)
|
||||
setMaxEmails(data.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +53,8 @@ export function ConfigPanel() {
|
||||
body: JSON.stringify({
|
||||
defaultRole,
|
||||
emailDomains,
|
||||
adminContact
|
||||
adminContact,
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -112,6 +119,20 @@ export function ConfigPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">最大邮箱数量:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={maxEmails}
|
||||
onChange={(e) => setMaxEmails(e.target.value)}
|
||||
placeholder={`默认为 ${EMAIL_CONFIG.MAX_ACTIVE_EMAILS}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function useAdminContact() {
|
||||
const [adminContact, setAdminContact] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { toast } = useToast()
|
||||
|
||||
const fetchAdminContact = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin-contact")
|
||||
if (!res.ok) throw new Error("获取管理员联系方式失败")
|
||||
const data = await res.json() as { adminContact: string }
|
||||
setAdminContact(data.adminContact)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: "获取失败",
|
||||
description: "获取管理员联系方式失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminContact()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
adminContact,
|
||||
loading,
|
||||
refreshAdminContact: fetchAdminContact
|
||||
}
|
||||
}
|
||||
62
app/hooks/use-config.ts
Normal file
62
app/hooks/use-config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { useEffect } from "react"
|
||||
|
||||
interface Config {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>
|
||||
emailDomains: string
|
||||
emailDomainsArray: string[]
|
||||
adminContact: string
|
||||
maxEmails: number
|
||||
}
|
||||
|
||||
interface ConfigStore {
|
||||
config: Config | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
fetch: () => Promise<void>
|
||||
}
|
||||
|
||||
const useConfigStore = create<ConfigStore>((set) => ({
|
||||
config: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
fetch: async () => {
|
||||
try {
|
||||
set({ loading: true, error: null })
|
||||
const res = await fetch("/api/config")
|
||||
if (!res.ok) throw new Error("获取配置失败")
|
||||
const data = await res.json() as Config
|
||||
set({
|
||||
config: {
|
||||
defaultRole: data.defaultRole || ROLES.CIVILIAN,
|
||||
emailDomains: data.emailDomains,
|
||||
emailDomainsArray: data.emailDomains.split(','),
|
||||
adminContact: data.adminContact || "",
|
||||
maxEmails: Number(data.maxEmails) || EMAIL_CONFIG.MAX_ACTIVE_EMAILS
|
||||
},
|
||||
loading: false
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "获取配置失败",
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
export function useConfig() {
|
||||
const store = useConfigStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!store.config && !store.loading) {
|
||||
store.fetch()
|
||||
}
|
||||
}, [store.config, store.loading])
|
||||
|
||||
return store
|
||||
}
|
||||
21
app/hooks/use-user-role.ts
Normal file
21
app/hooks/use-user-role.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import { Role } from "@/lib/permissions"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useUserRole() {
|
||||
const { data: session } = useSession()
|
||||
const [role, setRole] = useState<Role | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.roles?.[0]?.name) {
|
||||
setRole(session.user.roles[0].name as Role)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
return {
|
||||
role,
|
||||
loading: !session
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Metadata, Viewport } from "next"
|
||||
import { zpix } from "./fonts"
|
||||
import "./globals.css"
|
||||
import { Providers } from "./providers"
|
||||
import { FloatMenu } from "@/components/float-menu"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MoeMail - 萌萌哒临时邮箱服务",
|
||||
@@ -98,6 +99,7 @@ export default function RootLayout({
|
||||
{children}
|
||||
</Providers>
|
||||
<Toaster />
|
||||
<FloatMenu />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -52,6 +52,15 @@ export async function assignRoleToUser(db: Db, userId: string, roleId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserRole(userId: string) {
|
||||
const db = createDb()
|
||||
const userRoleRecords = await db.query.userRoles.findMany({
|
||||
where: eq(userRoles.userId, userId),
|
||||
with: { role: true },
|
||||
})
|
||||
return userRoleRecords[0].role.name
|
||||
}
|
||||
|
||||
export async function checkPermission(permission: Permission) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { integer, sqliteTable, text, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import type { AdapterAccountType } from "next-auth/adapters"
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
@@ -96,12 +96,14 @@ export const userRoles = sqliteTable("user_role", {
|
||||
export const apiKeys = sqliteTable('api_keys', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
key: text('key').notNull().unique(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
});
|
||||
}, (table) => ({
|
||||
nameUserIdUnique: uniqueIndex('name_user_id_unique').on(table.name, table.userId)
|
||||
}));
|
||||
|
||||
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
||||
user: one(users, {
|
||||
|
||||
2
drizzle/0011_simple_vulcan.sql
Normal file
2
drizzle/0011_simple_vulcan.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS `api_keys_name_unique`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `name_user_id_unique` ON `api_keys` (`name`,`user_id`);
|
||||
609
drizzle/meta/0011_snapshot.json
Normal file
609
drizzle/meta/0011_snapshot.json
Normal file
@@ -0,0 +1,609 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "b43606bc-3df5-471f-ac46-2c2eb52b1440",
|
||||
"prevId": "75ed0edc-e2f7-4782-b317-e16de23405f8",
|
||||
"tables": {
|
||||
"account": {
|
||||
"name": "account",
|
||||
"columns": {
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"providerAccountId": {
|
||||
"name": "providerAccountId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_userId_user_id_fk": {
|
||||
"name": "account_userId_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"account_provider_providerAccountId_pk": {
|
||||
"columns": [
|
||||
"provider",
|
||||
"providerAccountId"
|
||||
],
|
||||
"name": "account_provider_providerAccountId_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"api_keys": {
|
||||
"name": "api_keys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"api_keys_key_unique": {
|
||||
"name": "api_keys_key_unique",
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"name_user_id_unique": {
|
||||
"name": "name_user_id_unique",
|
||||
"columns": [
|
||||
"name",
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"api_keys_user_id_user_id_fk": {
|
||||
"name": "api_keys_user_id_user_id_fk",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"email_address_unique": {
|
||||
"name": "email_address_unique",
|
||||
"columns": [
|
||||
"address"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"email_userId_user_id_fk": {
|
||||
"name": "email_userId_user_id_fk",
|
||||
"tableFrom": "email",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emailId": {
|
||||
"name": "emailId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_address": {
|
||||
"name": "from_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subject": {
|
||||
"name": "subject",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"html": {
|
||||
"name": "html",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"received_at": {
|
||||
"name": "received_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"message_emailId_email_id_fk": {
|
||||
"name": "message_emailId_email_id_fk",
|
||||
"tableFrom": "message",
|
||||
"tableTo": "email",
|
||||
"columnsFrom": [
|
||||
"emailId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user_role": {
|
||||
"name": "user_role",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role_id": {
|
||||
"name": "role_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_role_user_id_user_id_fk": {
|
||||
"name": "user_role_user_id_user_id_fk",
|
||||
"tableFrom": "user_role",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"user_role_role_id_role_id_fk": {
|
||||
"name": "user_role_role_id_role_id_fk",
|
||||
"tableFrom": "user_role",
|
||||
"tableTo": "role",
|
||||
"columnsFrom": [
|
||||
"role_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_role_user_id_role_id_pk": {
|
||||
"columns": [
|
||||
"user_id",
|
||||
"role_id"
|
||||
],
|
||||
"name": "user_role_user_id_role_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"webhook": {
|
||||
"name": "webhook",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"webhook_user_id_user_id_fk": {
|
||||
"name": "webhook_user_id_user_id_fk",
|
||||
"tableFrom": "webhook",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,13 @@
|
||||
"when": 1739157879946,
|
||||
"tag": "0010_brief_stellaris",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1741490895849,
|
||||
"tag": "0011_simple_vulcan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export async function middleware(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
if (pathname === '/api/config' && request.method === 'GET') {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
for (const [route, permission] of Object.entries(API_PERMISSIONS)) {
|
||||
if (pathname.startsWith(route)) {
|
||||
const hasAccess = await checkPermission(permission)
|
||||
@@ -56,6 +60,5 @@ export const config = {
|
||||
'/api/roles/:path*',
|
||||
'/api/config/:path*',
|
||||
'/api/api-keys/:path*',
|
||||
'/api/admin-contact',
|
||||
]
|
||||
}
|
||||
17
package.json
17
package.json
@@ -8,14 +8,14 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"build:pages": "npx @cloudflare/next-on-pages",
|
||||
"db:migrate-local": "bun run scripts/migrate.ts local",
|
||||
"db:migrate-remote": "bun run scripts/migrate.ts remote",
|
||||
"db:migrate-local": "tsx scripts/migrate.ts local",
|
||||
"db:migrate-remote": "tsx scripts/migrate.ts remote",
|
||||
"webhook-test-server": "bun run scripts/webhook-test-server.ts",
|
||||
"generate-test-data": "wrangler dev scripts/generate-test-data.ts",
|
||||
"dev:cleanup": "wrangler dev --config wrangler.cleanup.toml --test-scheduled",
|
||||
"dev:cleanup": "wrangler dev --config wrangler.cleanup.json --test-scheduled",
|
||||
"test:cleanup": "curl http://localhost:8787/__scheduled",
|
||||
"deploy:email": "wrangler deploy --config wrangler.email.toml",
|
||||
"deploy:cleanup": "wrangler deploy --config wrangler.cleanup.toml",
|
||||
"deploy:email": "wrangler deploy --config wrangler.email.json",
|
||||
"deploy:cleanup": "wrangler deploy --config wrangler.cleanup.json",
|
||||
"deploy:pages": "npm run build:pages && wrangler pages deploy .vercel/output/static --branch main"
|
||||
},
|
||||
"type": "module",
|
||||
@@ -48,22 +48,25 @@
|
||||
"react-dom": "19.0.0",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241127.0",
|
||||
"@iarna/toml": "^3.0.0",
|
||||
"@types/bun": "^1.1.14",
|
||||
"@types/next-pwa": "^5.6.9",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"bun": "^1.1.39",
|
||||
"cloudflare": "^4.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.0.3",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5",
|
||||
"vercel": "39.1.1",
|
||||
"wrangler": "^3.91.0"
|
||||
|
||||
526
pnpm-lock.yaml
generated
526
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
99
scripts/deploy/cloudflare.ts
Normal file
99
scripts/deploy/cloudflare.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import Cloudflare from "cloudflare";
|
||||
import "dotenv/config";
|
||||
|
||||
const CF_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!;
|
||||
const CF_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN;
|
||||
const CUSTOM_DOMAIN = process.env.CUSTOM_DOMAIN;
|
||||
const PROJECT_NAME = process.env.PROJECT_NAME || "moemail";
|
||||
const DATABASE_NAME = process.env.DATABASE_NAME || "moemail-db";
|
||||
const KV_NAMESPACE_NAME = process.env.KV_NAMESPACE_NAME || "moemail-kv";
|
||||
const DATABASE_ID = process.env.DATABASE_ID;
|
||||
|
||||
const client = new Cloudflare({
|
||||
apiKey: CF_API_TOKEN,
|
||||
});
|
||||
|
||||
export const getPages = async () => {
|
||||
const projectInfo = await client.pages.projects.get(PROJECT_NAME, {
|
||||
account_id: CF_ACCOUNT_ID,
|
||||
});
|
||||
|
||||
return projectInfo;
|
||||
};
|
||||
|
||||
export const createPages = async () => {
|
||||
console.log(`🆕 Creating new Cloudflare Pages project: "${PROJECT_NAME}"`);
|
||||
|
||||
const project = await client.pages.projects.create({
|
||||
account_id: CF_ACCOUNT_ID,
|
||||
name: PROJECT_NAME,
|
||||
production_branch: "main",
|
||||
});
|
||||
|
||||
if (CUSTOM_DOMAIN) {
|
||||
console.log("🔗 Setting pages domain...");
|
||||
|
||||
await client.pages.projects.domains.create(PROJECT_NAME, {
|
||||
account_id: CF_ACCOUNT_ID,
|
||||
name: CUSTOM_DOMAIN,
|
||||
});
|
||||
|
||||
console.log("✅ Pages domain set successfully");
|
||||
}
|
||||
|
||||
console.log("✅ Project created successfully");
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
export const getDatabase = async () => {
|
||||
if (DATABASE_ID) {
|
||||
return {
|
||||
uuid: DATABASE_ID,
|
||||
}
|
||||
}
|
||||
|
||||
const database = await client.d1.database.get(DATABASE_NAME, {
|
||||
account_id: CF_ACCOUNT_ID,
|
||||
});
|
||||
|
||||
return database;
|
||||
};
|
||||
|
||||
export const createDatabase = async () => {
|
||||
console.log(`🆕 Creating new D1 database: "${DATABASE_NAME}"`);
|
||||
|
||||
const database = await client.d1.database.create({
|
||||
account_id: CF_ACCOUNT_ID,
|
||||
name: DATABASE_NAME,
|
||||
});
|
||||
|
||||
console.log("✅ Database created successfully");
|
||||
|
||||
return database;
|
||||
};
|
||||
|
||||
export const getKVNamespaceList = async () => {
|
||||
const kvNamespaces = [];
|
||||
|
||||
for await (const namespace of client.kv.namespaces.list({
|
||||
account_id: CF_ACCOUNT_ID,
|
||||
})) {
|
||||
kvNamespaces.push(namespace);
|
||||
}
|
||||
|
||||
return kvNamespaces;
|
||||
};
|
||||
|
||||
export const createKVNamespace = async () => {
|
||||
console.log(`🆕 Creating new KV namespace: "${KV_NAMESPACE_NAME}"`);
|
||||
|
||||
const kvNamespace = await client.kv.namespaces.create({
|
||||
account_id: CF_ACCOUNT_ID,
|
||||
title: KV_NAMESPACE_NAME,
|
||||
});
|
||||
|
||||
console.log("✅ KV namespace created successfully");
|
||||
|
||||
return kvNamespace;
|
||||
};
|
||||
448
scripts/deploy/index.ts
Normal file
448
scripts/deploy/index.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import { NotFoundError } from "cloudflare";
|
||||
import "dotenv/config";
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
createDatabase,
|
||||
createKVNamespace,
|
||||
createPages,
|
||||
getDatabase,
|
||||
getKVNamespaceList,
|
||||
getPages,
|
||||
} from "./cloudflare";
|
||||
|
||||
const PROJECT_NAME = process.env.PROJECT_NAME || "moemail";
|
||||
const DATABASE_NAME = process.env.DATABASE_NAME || "moemail-db";
|
||||
const KV_NAMESPACE_NAME = process.env.KV_NAMESPACE_NAME || "moemail-kv";
|
||||
const CUSTOM_DOMAIN = process.env.CUSTOM_DOMAIN;
|
||||
|
||||
const KV_NAMESPACE_ID = process.env.KV_NAMESPACE_ID;
|
||||
|
||||
/**
|
||||
* 验证必要的环境变量
|
||||
*/
|
||||
const validateEnvironment = () => {
|
||||
const requiredEnvVars = ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"];
|
||||
const missing = requiredEnvVars.filter((varName) => !process.env[varName]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables: ${missing.join(", ")}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理JSON配置文件
|
||||
*/
|
||||
const setupConfigFile = (examplePath: string, targetPath: string) => {
|
||||
try {
|
||||
// 如果目标文件已存在,则跳过
|
||||
if (existsSync(targetPath)) {
|
||||
console.log(`✨ Configuration ${targetPath} already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(examplePath)) {
|
||||
console.log(`⚠️ Example file ${examplePath} does not exist, skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
const configContent = readFileSync(examplePath, "utf-8");
|
||||
const json = JSON.parse(configContent);
|
||||
|
||||
// 处理数据库配置
|
||||
if (json.d1_databases && json.d1_databases.length > 0) {
|
||||
json.d1_databases[0].database_name = DATABASE_NAME;
|
||||
}
|
||||
|
||||
// 写入配置文件
|
||||
writeFileSync(targetPath, JSON.stringify(json, null, 2));
|
||||
console.log(`✅ Configuration ${targetPath} setup successfully.`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to setup ${targetPath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置所有Wrangler配置文件
|
||||
*/
|
||||
const setupWranglerConfigs = () => {
|
||||
console.log("🔧 Setting up Wrangler configuration files...");
|
||||
|
||||
const configs = [
|
||||
{ example: "wrangler.example.json", target: "wrangler.json" },
|
||||
{ example: "wrangler.email.example.json", target: "wrangler.email.json" },
|
||||
{ example: "wrangler.cleanup.example.json", target: "wrangler.cleanup.json" },
|
||||
];
|
||||
|
||||
// 处理每个配置文件
|
||||
for (const config of configs) {
|
||||
setupConfigFile(
|
||||
resolve(config.example),
|
||||
resolve(config.target)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新数据库ID到所有配置文件
|
||||
*/
|
||||
const updateDatabaseConfig = (dbId: string) => {
|
||||
console.log(`📝 Updating database ID (${dbId}) in configurations...`);
|
||||
|
||||
// 更新所有配置文件
|
||||
const configFiles = [
|
||||
"wrangler.json",
|
||||
"wrangler.email.json",
|
||||
"wrangler.cleanup.json",
|
||||
];
|
||||
|
||||
for (const filename of configFiles) {
|
||||
const configPath = resolve(filename);
|
||||
if (!existsSync(configPath)) continue;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
if (json.d1_databases && json.d1_databases.length > 0) {
|
||||
json.d1_databases[0].database_id = dbId;
|
||||
}
|
||||
writeFileSync(configPath, JSON.stringify(json, null, 2));
|
||||
console.log(`✅ Updated database ID in ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update ${filename}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新KV命名空间ID到所有配置文件
|
||||
*/
|
||||
const updateKVConfig = (namespaceId: string) => {
|
||||
console.log(`📝 Updating KV namespace ID (${namespaceId}) in configurations...`);
|
||||
|
||||
// KV命名空间只在主wrangler.json中使用
|
||||
const wranglerPath = resolve("wrangler.json");
|
||||
if (existsSync(wranglerPath)) {
|
||||
try {
|
||||
const json = JSON.parse(readFileSync(wranglerPath, "utf-8"));
|
||||
if (json.kv_namespaces && json.kv_namespaces.length > 0) {
|
||||
json.kv_namespaces[0].id = namespaceId;
|
||||
}
|
||||
writeFileSync(wranglerPath, JSON.stringify(json, null, 2));
|
||||
console.log(`✅ Updated KV namespace ID in wrangler.json`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update wrangler.json:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查并创建数据库
|
||||
*/
|
||||
const checkAndCreateDatabase = async () => {
|
||||
console.log(`🔍 Checking if database "${DATABASE_NAME}" exists...`);
|
||||
|
||||
try {
|
||||
const database = await getDatabase();
|
||||
|
||||
if (!database || !database.uuid) {
|
||||
throw new Error('Database object is missing a valid UUID');
|
||||
}
|
||||
|
||||
updateDatabaseConfig(database.uuid);
|
||||
console.log(`✅ Database "${DATABASE_NAME}" already exists (ID: ${database.uuid})`);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
console.log(`⚠️ Database not found, creating new database...`);
|
||||
try {
|
||||
const database = await createDatabase();
|
||||
|
||||
if (!database || !database.uuid) {
|
||||
throw new Error('Database object is missing a valid UUID');
|
||||
}
|
||||
|
||||
updateDatabaseConfig(database.uuid);
|
||||
console.log(`✅ Database "${DATABASE_NAME}" created successfully (ID: ${database.uuid})`);
|
||||
} catch (createError) {
|
||||
console.error(`❌ Failed to create database:`, createError);
|
||||
throw createError;
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ An error occurred while checking the database:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 迁移数据库
|
||||
*/
|
||||
const migrateDatabase = () => {
|
||||
console.log("📝 Migrating remote database...");
|
||||
try {
|
||||
execSync("pnpm run db:migrate-remote", { stdio: "inherit" });
|
||||
console.log("✅ Database migration completed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Database migration failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查并创建KV命名空间
|
||||
*/
|
||||
const checkAndCreateKVNamespace = async () => {
|
||||
console.log(`🔍 Checking if KV namespace "${KV_NAMESPACE_NAME}" exists...`);
|
||||
|
||||
if (KV_NAMESPACE_ID) {
|
||||
updateKVConfig(KV_NAMESPACE_ID);
|
||||
console.log(`✅ User specified KV namespace (ID: ${KV_NAMESPACE_ID})`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let namespace;
|
||||
|
||||
const namespaceList = await getKVNamespaceList();
|
||||
namespace = namespaceList.find(ns => ns.title === KV_NAMESPACE_NAME);
|
||||
|
||||
if (namespace && namespace.id) {
|
||||
updateKVConfig(namespace.id);
|
||||
console.log(`✅ KV namespace "${KV_NAMESPACE_NAME}" found by name (ID: ${namespace.id})`);
|
||||
} else {
|
||||
console.log("⚠️ KV namespace not found by name, creating new KV namespace...");
|
||||
namespace = await createKVNamespace();
|
||||
updateKVConfig(namespace.id);
|
||||
console.log(`✅ KV namespace "${KV_NAMESPACE_NAME}" created successfully (ID: ${namespace.id})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ An error occurred while checking the KV namespace:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查并创建Pages项目
|
||||
*/
|
||||
const checkAndCreatePages = async () => {
|
||||
console.log(`🔍 Checking if project "${PROJECT_NAME}" exists...`);
|
||||
|
||||
try {
|
||||
await getPages();
|
||||
console.log("✅ Project already exists, proceeding with update...");
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
console.log("⚠️ Project not found, creating new project...");
|
||||
const pages = await createPages();
|
||||
|
||||
if (!CUSTOM_DOMAIN && pages.subdomain) {
|
||||
console.log("⚠️ CUSTOM_DOMAIN is empty, using pages default domain...");
|
||||
console.log("📝 Updating environment variables...");
|
||||
|
||||
// 更新环境变量为默认的Pages域名
|
||||
const appUrl = `https://${pages.subdomain}`;
|
||||
updateEnvVar("CUSTOM_DOMAIN", appUrl);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ An error occurred while checking the project:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 推送Pages密钥
|
||||
*/
|
||||
const pushPagesSecret = () => {
|
||||
console.log("🔐 Pushing environment secrets to Pages...");
|
||||
|
||||
// 定义运行时所需的环境变量列表
|
||||
const runtimeEnvVars = ['AUTH_GITHUB_ID', 'AUTH_GITHUB_SECRET', 'AUTH_SECRET'];
|
||||
|
||||
// 兼容老的部署方式,如果这些环境变量不存在,则说明是老的部署方式,跳过推送
|
||||
for (const varName of runtimeEnvVars) {
|
||||
if (!process.env[varName]) {
|
||||
console.log(`🔐 Skipping pushing secrets to Pages...`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保.env文件存在
|
||||
if (!existsSync(resolve('.env'))) {
|
||||
setupEnvFile();
|
||||
}
|
||||
|
||||
// 创建一个临时文件,只包含运行时所需的环境变量
|
||||
const envContent = readFileSync(resolve('.env'), 'utf-8');
|
||||
const runtimeEnvFile = resolve('.env.runtime');
|
||||
|
||||
// 从.env文件中提取运行时变量
|
||||
const runtimeEnvContent = envContent
|
||||
.split('\n')
|
||||
.filter(line => {
|
||||
const trimmedLine = line.trim();
|
||||
// 跳过注释和空行
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) return false;
|
||||
|
||||
// 检查是否为运行时所需的环境变量
|
||||
for (const varName of runtimeEnvVars) {
|
||||
if (line.startsWith(`${varName} =`) || line.startsWith(`${varName}=`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// 写入临时文件
|
||||
writeFileSync(runtimeEnvFile, runtimeEnvContent);
|
||||
|
||||
// 使用临时文件推送secrets
|
||||
execSync(`pnpm dlx wrangler pages secret bulk ${runtimeEnvFile}`, { stdio: "inherit" });
|
||||
|
||||
// 清理临时文件
|
||||
execSync(`rm ${runtimeEnvFile}`, { stdio: "inherit" });
|
||||
|
||||
console.log("✅ Secrets pushed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to push secrets:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 部署Pages应用
|
||||
*/
|
||||
const deployPages = () => {
|
||||
console.log("🚧 Deploying to Cloudflare Pages...");
|
||||
try {
|
||||
execSync("pnpm run deploy:pages", { stdio: "inherit" });
|
||||
console.log("✅ Pages deployment completed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Pages deployment failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 部署Email Worker
|
||||
*/
|
||||
const deployEmailWorker = () => {
|
||||
console.log("🚧 Deploying Email Worker...");
|
||||
try {
|
||||
execSync("pnpm dlx wrangler deploy --config wrangler.email.json", { stdio: "inherit" });
|
||||
console.log("✅ Email Worker deployed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Email Worker deployment failed:", error);
|
||||
// 继续执行而不中断
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 部署Cleanup Worker
|
||||
*/
|
||||
const deployCleanupWorker = () => {
|
||||
console.log("🚧 Deploying Cleanup Worker...");
|
||||
try {
|
||||
execSync("pnpm dlx wrangler deploy --config wrangler.cleanup.json", { stdio: "inherit" });
|
||||
console.log("✅ Cleanup Worker deployed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Cleanup Worker deployment failed:", error);
|
||||
// 继续执行而不中断
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建或更新环境变量文件
|
||||
*/
|
||||
const setupEnvFile = () => {
|
||||
console.log("📄 Setting up environment file...");
|
||||
const envFilePath = resolve(".env");
|
||||
const envExamplePath = resolve(".env.example");
|
||||
|
||||
// 如果.env文件不存在,则从.env.example复制创建
|
||||
if (!existsSync(envFilePath) && existsSync(envExamplePath)) {
|
||||
console.log("⚠️ .env file does not exist, creating from example...");
|
||||
|
||||
// 从示例文件复制
|
||||
let envContent = readFileSync(envExamplePath, "utf-8");
|
||||
|
||||
// 填充当前的环境变量
|
||||
const envVarMatches = envContent.match(/^([A-Z_]+)\s*=\s*".*?"/gm);
|
||||
if (envVarMatches) {
|
||||
for (const match of envVarMatches) {
|
||||
const varName = match.split("=")[0].trim();
|
||||
if (process.env[varName]) {
|
||||
const regex = new RegExp(`${varName}\\s*=\\s*".*?"`, "g");
|
||||
envContent = envContent.replace(regex, `${varName} = "${process.env[varName]}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(envFilePath, envContent);
|
||||
console.log("✅ .env file created from example");
|
||||
} else if (existsSync(envFilePath)) {
|
||||
console.log("✨ .env file already exists");
|
||||
} else {
|
||||
console.error("❌ .env.example file not found!");
|
||||
throw new Error(".env.example file not found");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新环境变量
|
||||
*/
|
||||
const updateEnvVar = (name: string, value: string) => {
|
||||
// 首先更新进程环境变量
|
||||
process.env[name] = value;
|
||||
|
||||
// 然后尝试更新.env文件
|
||||
const envFilePath = resolve(".env");
|
||||
if (!existsSync(envFilePath)) {
|
||||
setupEnvFile();
|
||||
}
|
||||
|
||||
let envContent = readFileSync(envFilePath, "utf-8");
|
||||
const regex = new RegExp(`^${name}\\s*=\\s*".*?"`, "m");
|
||||
|
||||
if (envContent.match(regex)) {
|
||||
envContent = envContent.replace(regex, `${name} = "${value}"`);
|
||||
} else {
|
||||
envContent += `\n${name} = "${value}"`;
|
||||
}
|
||||
|
||||
writeFileSync(envFilePath, envContent);
|
||||
console.log(`✅ Updated ${name} in .env file`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 主函数
|
||||
*/
|
||||
const main = async () => {
|
||||
try {
|
||||
console.log("🚀 Starting deployment process...");
|
||||
|
||||
validateEnvironment();
|
||||
setupEnvFile();
|
||||
setupWranglerConfigs();
|
||||
await checkAndCreateDatabase();
|
||||
migrateDatabase();
|
||||
await checkAndCreateKVNamespace();
|
||||
await checkAndCreatePages();
|
||||
pushPagesSecret();
|
||||
deployPages();
|
||||
deployEmailWorker();
|
||||
deployCleanupWorker();
|
||||
|
||||
console.log("🎉 Deployment completed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Deployment failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
main();
|
||||
@@ -1,8 +1,7 @@
|
||||
import { drizzle } from 'drizzle-orm/d1'
|
||||
import { emails, messages } from '../app/lib/schema'
|
||||
import { emails, messages, users } from '../app/lib/schema'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
const TEST_USER_ID = '4e4c1d5d-a3c9-407a-8808-2a2424b38c62'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
@@ -12,16 +11,44 @@ const MAX_EMAIL_COUNT = 5
|
||||
const MAX_MESSAGE_COUNT = 50
|
||||
const BATCH_SIZE = 10 // SQLite 变量限制
|
||||
|
||||
async function generateTestData(env: Env) {
|
||||
async function getUserId(db: ReturnType<typeof drizzle>, identifier: string): Promise<string | null> {
|
||||
// 尝试通过 email 查找用户
|
||||
let user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, identifier))
|
||||
.limit(1)
|
||||
.then(rows => rows[0])
|
||||
|
||||
// 如果没找到,尝试通过 username 查找
|
||||
if (!user) {
|
||||
user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, identifier))
|
||||
.limit(1)
|
||||
.then(rows => rows[0])
|
||||
}
|
||||
|
||||
return user?.id || null
|
||||
}
|
||||
|
||||
async function generateTestData(env: Env, userIdentifier: string) {
|
||||
const db = drizzle(env.DB)
|
||||
const now = new Date()
|
||||
|
||||
try {
|
||||
// 获取用户 ID
|
||||
const userId = await getUserId(db, userIdentifier)
|
||||
if (!userId) {
|
||||
throw new Error(`未找到用户: ${userIdentifier}`)
|
||||
}
|
||||
|
||||
// 生成测试邮箱
|
||||
const testEmails = Array.from({ length: MAX_EMAIL_COUNT }).map(() => ({
|
||||
id: crypto.randomUUID(),
|
||||
address: `${nanoid(6)}@moemail.app`,
|
||||
userId: TEST_USER_ID,
|
||||
userId: userId,
|
||||
createdAt: now,
|
||||
expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
|
||||
}))
|
||||
@@ -66,7 +93,14 @@ async function generateTestData(env: Env) {
|
||||
export default {
|
||||
async fetch(request: Request, env: Env) {
|
||||
if (request.method === 'GET') {
|
||||
await generateTestData(env)
|
||||
const url = new URL(request.url)
|
||||
const userIdentifier = url.searchParams.get('user')
|
||||
|
||||
if (!userIdentifier) {
|
||||
return new Response('Missing user parameter', { status: 400 })
|
||||
}
|
||||
|
||||
await generateTestData(env, userIdentifier)
|
||||
return new Response('Test data generated successfully', { status: 200 })
|
||||
}
|
||||
return new Response('Method not allowed', { status: 405 })
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parse } from '@iarna/toml'
|
||||
import { readFileSync } from 'fs'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
@@ -27,23 +26,23 @@ async function migrate() {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Read wrangler.toml
|
||||
const wranglerPath = join(process.cwd(), 'wrangler.toml')
|
||||
// Read wrangler.json
|
||||
const wranglerPath = join(process.cwd(), 'wrangler.json')
|
||||
let wranglerContent: string
|
||||
|
||||
try {
|
||||
wranglerContent = readFileSync(wranglerPath, 'utf-8')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (error) {
|
||||
console.error('Error: wrangler.toml not found')
|
||||
console.error('Error: wrangler.json not found')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Parse wrangler.toml
|
||||
const config = parse(wranglerContent) as unknown as WranglerConfig
|
||||
// Parse wrangler.json
|
||||
const config = JSON.parse(wranglerContent) as WranglerConfig
|
||||
|
||||
if (!config.d1_databases?.[0]?.database_name) {
|
||||
console.error('Error: Database name not found in wrangler.toml')
|
||||
console.error('Error: Database name not found in wrangler.json')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@ async function migrate() {
|
||||
console.log('Generating migrations...')
|
||||
await execAsync('drizzle-kit generate')
|
||||
|
||||
// Apply migrations
|
||||
// Applying migrations
|
||||
console.log(`Applying migrations to ${mode} database: ${dbName}`)
|
||||
await execAsync(`wrangler d1 migrations apply ${dbName} --${mode}`)
|
||||
|
||||
@@ -64,4 +63,4 @@ async function migrate() {
|
||||
}
|
||||
}
|
||||
|
||||
migrate()
|
||||
migrate()
|
||||
18
wrangler.cleanup.example.json
Normal file
18
wrangler.cleanup.example.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "cleanup-worker",
|
||||
"main": "workers/cleanup.ts",
|
||||
"compatibility_date": "2024-03-20",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"triggers": {
|
||||
"crons": ["0 * * * *"]
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"migrations_dir": "drizzle",
|
||||
"database_name": "moemail",
|
||||
"database_id": "${DATABASE_ID}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
name = "cleanup-worker"
|
||||
main = "workers/cleanup.ts"
|
||||
compatibility_date = "2024-03-20"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
# 每 1 小时运行一次
|
||||
[triggers]
|
||||
crons = ["0 * * * *"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
migrations_dir = "drizzle"
|
||||
database_name = ""
|
||||
database_id = ""
|
||||
15
wrangler.email.example.json
Normal file
15
wrangler.email.example.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "email-receiver-worker",
|
||||
"compatibility_date": "2024-03-20",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"main": "workers/email-receiver.ts",
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"migrations_dir": "drizzle",
|
||||
"database_name": "moemail",
|
||||
"database_id": "${DATABASE_ID}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
name = "email-receiver-worker"
|
||||
compatibility_date = "2024-03-20"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
main = "workers/email-receiver.ts"
|
||||
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
migrations_dir = "drizzle"
|
||||
database_name = ""
|
||||
database_id = ""
|
||||
21
wrangler.example.json
Normal file
21
wrangler.example.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "moemail",
|
||||
"compatibility_date": "2024-03-20",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"pages_build_output_dir": ".vercel/output/static",
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "moemail",
|
||||
"database_id": "${DATABASE_ID}",
|
||||
"migrations_dir": "drizzle"
|
||||
}
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "SITE_CONFIG",
|
||||
"id": "${KV_NAMESPACE_ID}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
name = "moemail"
|
||||
compatibility_date = "2024-03-20"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
pages_build_output_dir = ".vercel/output/static"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
migrations_dir = "drizzle"
|
||||
database_name = ""
|
||||
database_id = ""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "SITE_CONFIG"
|
||||
id = ""
|
||||
Reference in New Issue
Block a user