diff --git a/README.md b/README.md index c2b969a..2f04fd5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ 本地运行部署Cloudflare 邮件路由配置 • + 权限系统Webhook 集成环境变量Github OAuth App 配置 • @@ -31,7 +32,7 @@ ![邮箱](https://pic.otaku.ren/20241209/AQADw8UxG9k1uVZ-.jpg "邮箱") -![个人中心](https://pic.otaku.ren/20241217/AQAD9sQxG0g1EVd-.jpg "个人中心") +![个人中心](https://pic.otaku.ren/20241227/AQADVsIxG7OzcFd-.jpg "个人中心") ## 特性 @@ -45,6 +46,7 @@ - 💸 **免费自部署**:基于 Cloudflare 构建, 可实现免费自部署,无需任何费用 - 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面 - 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知 +- 🛡️ **权限系统**:支持基于角色的权限控制系统 ## 技术栈 @@ -63,7 +65,7 @@ ### 前置要求 - Node.js 18+ -- pnpm +- Pnpm - Wrangler CLI - Cloudflare 账号 @@ -241,6 +243,45 @@ pnpm deploy:cleanup - 确保域名的 DNS 托管在 Cloudflare - Email Worker 必须已经部署成功 +## 权限系统 + +本项目采用基于角色的权限控制系统(RBAC)。 + +### 角色等级 + +系统包含三个角色等级: + +1. **皇帝(Emperor)** + - 网站所有者 + - 拥有所有权限 + - 每个站点仅允许一位皇帝 + +2. **骑士(Knight)** + - 高级用户 + - 可以使用临时邮箱功能 + - 可以配置 Webhook + - 开放注册时默认角色 + +3. **平民(Civilian)** + - 普通用户 + - 无任何权限 + - 非开放注册时默认角色 + +### 权限配置 + +通过环境变量 `OPEN_REGISTRATION` 控制注册策略: +- `true`: 新用户默认为骑士 +- `false`: 新用户默认为平民 + +### 角色升级 + +1. **成为皇帝** + - 第一个访问 `/api/roles/init-emperor` 接口的用户将成为皇帝 + - 站点已有皇帝后,无法再提升其他用户为皇帝 + +2. **成为骑士** + - 皇帝在个人中心页面对平民进行册封 + ## Webhook 集成 @@ -301,6 +342,9 @@ pnpx cloudflared tunnel --url http://localhost:3001 - `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret - `AUTH_SECRET`: NextAuth Secret,用来加密 session,请设置一个随机字符串 +### 权限相关 +- `OPEN_REGISTRATION`: 是否开放注册,`true` 表示开放注册,`false` 表示关闭注册 + ### 邮箱配置 - `NEXT_PUBLIC_EMAIL_DOMAIN`: 邮箱域名,支持多域名,用逗号分隔 (例如: moemail.app,bitibiti.com) @@ -335,7 +379,7 @@ pnpx cloudflared tunnel --url http://localhost:3001 ## 支持 如果你喜欢这个项目,欢迎给它一个 Star ⭐️ -或者进行赞助 +或者进���赞助

diff --git a/app/api/roles/init-emperor/route.ts b/app/api/roles/init-emperor/route.ts new file mode 100644 index 0000000..62eb3c0 --- /dev/null +++ b/app/api/roles/init-emperor/route.ts @@ -0,0 +1,54 @@ +import { auth } from "@/lib/auth"; +import { createDb } from "@/lib/db"; +import { roles, userRoles } from "@/lib/schema"; +import { ROLES } from "@/lib/permissions"; +import { eq } from "drizzle-orm"; + +export const runtime = "edge"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return Response.json({ error: "未授权" }, { status: 401 }); + } + + const db = createDb(); + + const emperorRole = await db.query.roles.findFirst({ + where: eq(roles.name, ROLES.EMPEROR), + with: { + userRoles: true, + }, + }); + + if (emperorRole && emperorRole.userRoles.length > 0) { + return Response.json({ error: "已存在皇帝, 谋反将被处死" }, { status: 400 }); + } + + try { + let roleId = emperorRole?.id; + if (!roleId) { + const [newRole] = await db.insert(roles) + .values({ + name: ROLES.EMPEROR, + description: "皇帝(网站所有者)", + }) + .returning({ id: roles.id }); + roleId = newRole.id; + } + + await db.insert(userRoles) + .values({ + userId: session.user.id, + roleId, + }); + + return Response.json({ message: "登基成功,你已成为皇帝" }); + } catch (error) { + console.error("Failed to initialize emperor:", error); + return Response.json( + { error: "登基称帝失败" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/roles/promote/route.ts b/app/api/roles/promote/route.ts new file mode 100644 index 0000000..11f6887 --- /dev/null +++ b/app/api/roles/promote/route.ts @@ -0,0 +1,58 @@ +import { createDb } from "@/lib/db"; +import { roles, userRoles } from "@/lib/schema"; +import { eq } from "drizzle-orm"; +import { ROLES } from "@/lib/permissions"; + +export const runtime = "edge"; + +export async function POST(request: Request) { + try { + const { userId, roleName } = await request.json() as { userId: string, roleName: string }; + if (!userId || !roleName) { + return Response.json( + { error: "缺少必要参数" }, + { status: 400 } + ); + } + + if (roleName !== ROLES.KNIGHT) { + return Response.json( + { error: "角色不合法" }, + { status: 400 } + ); + } + + const db = createDb(); + + let targetRole = await db.query.roles.findFirst({ + where: eq(roles.name, roleName), + }); + + if (!targetRole) { + const [newRole] = await db.insert(roles) + .values({ + name: roleName, + description: "高级用户", + }) + .returning(); + targetRole = newRole; + } + + await db.delete(userRoles) + .where(eq(userRoles.userId, userId)); + + await db.insert(userRoles) + .values({ + userId, + roleId: targetRole.id, + }); + + return Response.json({ success: true }); + } catch (error) { + console.error("Failed to promote user:", error); + return Response.json( + { error: "升级用户失败" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/roles/users/route.ts b/app/api/roles/users/route.ts new file mode 100644 index 0000000..ed78dda --- /dev/null +++ b/app/api/roles/users/route.ts @@ -0,0 +1,43 @@ +import { createDb } from "@/lib/db" +import { userRoles, users } from "@/lib/schema" +import { eq } from "drizzle-orm" + +export const runtime = "edge" + +export async function GET(request: Request) { + const url = new URL(request.url) + const email = url.searchParams.get('email') + + if (!email) { + return Response.json( + { error: "邮箱地址不能为空" }, + { status: 400 } + ) + } + + const db = createDb() + + const user = await db.query.users.findFirst({ + where: eq(users.email, email), + }) + + if (!user) { + return Response.json({ user: null }) + } + + const userRole = await db.query.userRoles.findFirst({ + where: eq(userRoles.userId, user.id), + with: { + role: true + } + }) + + return Response.json({ + user: { + id: user.id, + name: user.name, + email: user.email, + role: userRole?.role.name + } + }) +} \ No newline at end of file diff --git a/app/components/emails/create-dialog.tsx b/app/components/emails/create-dialog.tsx index 2e87943..f715b21 100644 --- a/app/components/emails/create-dialog.tsx +++ b/app/components/emails/create-dialog.tsx @@ -90,8 +90,8 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) { const fetchDomains = async () => { const response = await fetch("/api/emails/domains"); const data = (await response.json()) as DomainResponse; - setDomains(data.domains); - setCurrentDomain(data.domains[0]); + setDomains(data.domains || []); + setCurrentDomain(data.domains[0] || ""); }; useEffect(() => { diff --git a/app/components/no-permission-dialog.tsx b/app/components/no-permission-dialog.tsx new file mode 100644 index 0000000..09349bc --- /dev/null +++ b/app/components/no-permission-dialog.tsx @@ -0,0 +1,27 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" + +export function NoPermissionDialog() { + const router = useRouter() + + return ( +
+
+
+
+

权限不足

+

你没有权限访问此页面,请联系网站皇帝授权

+ +
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/components/profile/profile-card.tsx b/app/components/profile/profile-card.tsx index 1e64e14..24b27e5 100644 --- a/app/components/profile/profile-card.tsx +++ b/app/components/profile/profile-card.tsx @@ -4,20 +4,31 @@ import { User } from "next-auth" import Image from "next/image" import { Button } from "@/components/ui/button" import { signOut } from "next-auth/react" -import { Github, Mail, Settings } from "lucide-react" +import { Github, Mail, Settings, Crown, Sword, User2 } from "lucide-react" import { useRouter } from "next/navigation" import { WebhookConfig } from "./webhook-config" +import { PromotePanel } from "./promote-panel" +import { useRolePermission } from "@/hooks/use-role-permission" +import { PERMISSIONS } from "@/lib/permissions" interface ProfileCardProps { user: User } +const roleConfigs = { + emperor: { name: '皇帝', icon: Crown }, + knight: { name: '骑士', icon: Sword }, + civilian: { name: '平民', icon: User2 }, +} as const + export function ProfileCard({ user }: ProfileCardProps) { const router = useRouter() + const { checkPermission } = useRolePermission() + const canManageWebhook = checkPermission(PERMISSIONS.MANAGE_WEBHOOK) + const canPromote = checkPermission(PERMISSIONS.PROMOTE_USER) return (
- {/* 用户信息卡片 */}
@@ -42,20 +53,40 @@ export function ProfileCard({ user }: ProfileCardProps) {

{user.email}

+ {user.roles && ( +
+ {user.roles.map(({ name }) => { + const roleConfig = roleConfigs[name as keyof typeof roleConfigs] + const Icon = roleConfig.icon + return ( +
+ + {roleConfig.name} +
+ ) + })} +
+ )}
- {/* Webhook 配置卡片 */} -
-
- -

Webhook 配置

+ {canManageWebhook && ( +
+
+ +

Webhook 配置

+
+
- -
+ )} + + {canPromote && } - {/* 操作按钮 */}
+
+
+ ) +} \ No newline at end of file diff --git a/app/hooks/use-role-permission.ts b/app/hooks/use-role-permission.ts new file mode 100644 index 0000000..bb7f66c --- /dev/null +++ b/app/hooks/use-role-permission.ts @@ -0,0 +1,25 @@ +"use client" + +import { useSession } from "next-auth/react" +import { Permission, Role, hasPermission } from "@/lib/permissions" + +export function useRolePermission() { + const { data: session } = useSession() + const roles = session?.user?.roles + + const checkPermission = (permission: Permission) => { + if (!roles) return false + return hasPermission(roles.map(r => r.name) as Role[], permission) + } + + const hasRole = (role: Role) => { + if (!roles) return false + return roles.some(r => r.name === role) + } + + return { + checkPermission, + hasRole, + roles, + } +} \ No newline at end of file diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 0883c1d..70a815c 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -1,27 +1,127 @@ import NextAuth from "next-auth" import GitHub from "next-auth/providers/github" import { DrizzleAdapter } from "@auth/drizzle-adapter" -import { createDb } from "./db" -import { accounts, sessions, users } from "./schema" +import { createDb, Db } from "./db" +import { accounts, sessions, users, roles, userRoles } from "./schema" +import { eq } from "drizzle-orm" +import { Permission, hasPermission, ROLES, Role } from "./permissions" + +const ROLE_DESCRIPTIONS: Record = { + [ROLES.EMPEROR]: "皇帝(网站所有者)", + [ROLES.KNIGHT]: "骑士(高级用户)", + [ROLES.CIVILIAN]: "平民(普通用户)", +} + +const getDefaultRole = (): Role => + process.env.OPEN_REGISTRATION === 'true' ? ROLES.KNIGHT : ROLES.CIVILIAN + +async function findOrCreateRole(db: Db, roleName: Role) { + let role = await db.query.roles.findFirst({ + where: eq(roles.name, roleName), + }) + + if (!role) { + const [newRole] = await db.insert(roles) + .values({ + name: roleName, + description: ROLE_DESCRIPTIONS[roleName], + }) + .returning() + role = newRole + } + + return role +} + +async function assignRoleToUser(db: Db, userId: string, roleId: string) { + await db.insert(userRoles) + .values({ + userId, + roleId, + }) +} + +export async function checkPermission(permission: Permission) { + const session = await auth() + if (!session?.user?.id) return false + + const db = createDb() + const userRoleRecords = await db.query.userRoles.findMany({ + where: eq(userRoles.userId, session.user.id), + with: { role: true }, + }) + + const userRoleNames = userRoleRecords.map(ur => ur.role.name) + return hasPermission(userRoleNames as Role[], permission) +} export const { handlers: { GET, POST }, auth, signIn, signOut -} = NextAuth(() => { - return { - secret: process.env.AUTH_SECRET, - adapter: DrizzleAdapter(createDb(), { - usersTable: users, - accountsTable: accounts, - sessionsTable: sessions, - }), - providers: [ - GitHub({ - clientId: process.env.AUTH_GITHUB_ID, - clientSecret: process.env.AUTH_GITHUB_SECRET, +} = NextAuth(() => ({ + secret: process.env.AUTH_SECRET, + adapter: DrizzleAdapter(createDb(), { + usersTable: users, + accountsTable: accounts, + sessionsTable: sessions, + }), + providers: [ + GitHub({ + clientId: process.env.AUTH_GITHUB_ID, + clientSecret: process.env.AUTH_GITHUB_SECRET, + }) + ], + events: { + async signIn({ user }) { + if (!user.id) return + + try { + const db = createDb() + const existingRole = await db.query.userRoles.findFirst({ + where: eq(userRoles.userId, user.id), + }) + + if (existingRole) return + + const defaultRole = await findOrCreateRole(db, getDefaultRole()) + await assignRoleToUser(db, user.id, defaultRole.id) + } catch (error) { + console.error('Error assigning role:', error) + } + }, + }, + pages: { + signIn: "/", + error: "/", + }, + callbacks: { + async session({ session, user }) { + if (!session?.user) return session + + const db = createDb() + let userRoleRecords = await db.query.userRoles.findMany({ + where: eq(userRoles.userId, user.id), + with: { role: true }, }) - ], + + if (!userRoleRecords.length) { + const defaultRole = await findOrCreateRole(db, getDefaultRole()) + await assignRoleToUser(db, user.id, defaultRole.id) + userRoleRecords = [{ + userId: user.id, + roleId: defaultRole.id, + createdAt: new Date(), + role: defaultRole + }] + } + + session.user.roles = userRoleRecords.map(ur => ({ + name: ur.role.name, + })) + + return session + }, } -}) +})) diff --git a/app/lib/db.ts b/app/lib/db.ts index 4030529..e4fdd4b 100644 --- a/app/lib/db.ts +++ b/app/lib/db.ts @@ -2,4 +2,6 @@ import { getRequestContext } from "@cloudflare/next-on-pages" import { drizzle } from "drizzle-orm/d1" import * as schema from "./schema" -export const createDb = () => drizzle(getRequestContext().env.DB, { schema }) \ No newline at end of file +export const createDb = () => drizzle(getRequestContext().env.DB, { schema }) + +export type Db = ReturnType diff --git a/app/lib/permissions.ts b/app/lib/permissions.ts new file mode 100644 index 0000000..b9204a7 --- /dev/null +++ b/app/lib/permissions.ts @@ -0,0 +1,28 @@ +export const ROLES = { + EMPEROR: 'emperor', + KNIGHT: 'knight', + CIVILIAN: 'civilian', +} as const; + +export type Role = typeof ROLES[keyof typeof ROLES]; + +export const PERMISSIONS = { + MANAGE_EMAIL: 'manage_email', + MANAGE_WEBHOOK: 'manage_webhook', + PROMOTE_USER: 'promote_user', +} as const; + +export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS]; + +export const ROLE_PERMISSIONS: Record = { + [ROLES.EMPEROR]: Object.values(PERMISSIONS), + [ROLES.KNIGHT]: [ + PERMISSIONS.MANAGE_EMAIL, + PERMISSIONS.MANAGE_WEBHOOK, + ], + [ROLES.CIVILIAN]: [], +} as const; + +export function hasPermission(userRoles: Role[], permission: Permission): boolean { + return userRoles.some(role => ROLE_PERMISSIONS[role]?.includes(permission)); +} \ No newline at end of file diff --git a/app/lib/schema.ts b/app/lib/schema.ts index 9852c1d..e126811 100644 --- a/app/lib/schema.ts +++ b/app/lib/schema.ts @@ -1,6 +1,7 @@ import { integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core" import type { AdapterAccountType } from "next-auth/adapters" - +import { relations } from 'drizzle-orm'; + // https://authjs.dev/getting-started/adapters/drizzle export const users = sqliteTable("user", { id: text("id") @@ -81,4 +82,35 @@ export const webhooks = sqliteTable('webhook', { updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) .notNull() .$defaultFn(() => new Date()), -}) \ No newline at end of file +}) + +export const roles = sqliteTable("role", { + id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), + name: text("name").notNull(), + description: text("description"), + createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()), +}); + +export const rolesRelations = relations(roles, ({ many }) => ({ + userRoles: many(userRoles), +})); + +export const userRoles = sqliteTable("user_role", { + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + roleId: text("role_id").notNull().references(() => roles.id, { onDelete: "cascade" }), + createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), +}, (table) => ({ + pk: primaryKey({ columns: [table.userId, table.roleId] }), +})); + +export const userRolesRelations = relations(userRoles, ({ one }) => ({ + user: one(users, { + fields: [userRoles.userId], + references: [users.id], + }), + role: one(roles, { + fields: [userRoles.roleId], + references: [roles.id], + }), +})); \ No newline at end of file diff --git a/app/moe/no-permission/page.tsx b/app/moe/no-permission/page.tsx new file mode 100644 index 0000000..fdb4243 --- /dev/null +++ b/app/moe/no-permission/page.tsx @@ -0,0 +1,24 @@ +import { Header } from "@/components/layout/header" +import { Button } from "@/components/ui/button" +import { Crown } from "lucide-react" +import Link from "next/link" + +export default function NoPermissionPage() { + return ( +
+
+
+
+
+ +

权限不足

+

你没有权限访问此页面,请联系皇帝

+ + + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/moe/page.tsx b/app/moe/page.tsx index a25e376..73f596b 100644 --- a/app/moe/page.tsx +++ b/app/moe/page.tsx @@ -1,23 +1,29 @@ import { Header } from "@/components/layout/header" import { ThreeColumnLayout } from "@/components/emails/three-column-layout" +import { NoPermissionDialog } from "@/components/no-permission-dialog" import { auth } from "@/lib/auth" import { redirect } from "next/navigation" +import { checkPermission } from "@/lib/auth" +import { PERMISSIONS } from "@/lib/permissions" export const runtime = "edge" export default async function MoePage() { const session = await auth() - if (!session) { + if (!session?.user) { redirect("/") } + const hasPermission = await checkPermission(PERMISSIONS.MANAGE_EMAIL) + return (
+ {!hasPermission && }
diff --git a/drizzle/0004_panoramic_hedge_knight.sql b/drizzle/0004_panoramic_hedge_knight.sql new file mode 100644 index 0000000..ffc6676 --- /dev/null +++ b/drizzle/0004_panoramic_hedge_knight.sql @@ -0,0 +1,33 @@ +CREATE TABLE `permission` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `description` text, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +CREATE TABLE `role_permission` ( + `role_id` text NOT NULL, + `permission_id` text NOT NULL, + `created_at` integer, + PRIMARY KEY(`role_id`, `permission_id`), + FOREIGN KEY (`role_id`) REFERENCES `role`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`permission_id`) REFERENCES `permission`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `role` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `description` text, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +CREATE TABLE `user_role` ( + `user_id` text NOT NULL, + `role_id` text NOT NULL, + `created_at` integer, + PRIMARY KEY(`user_id`, `role_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`role_id`) REFERENCES `role`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/0005_tricky_mathemanic.sql b/drizzle/0005_tricky_mathemanic.sql new file mode 100644 index 0000000..6c0d5de --- /dev/null +++ b/drizzle/0005_tricky_mathemanic.sql @@ -0,0 +1,2 @@ +DROP TABLE `permission`;--> statement-breakpoint +DROP TABLE `role_permission`; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..ecffcfe --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,654 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f54d0fb3-1aba-4100-89b8-6aaa32d52eba", + "prevId": "c65780fc-6545-438d-a3b6-fbdafa9b1276", + "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": {} + }, + "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": {} + }, + "permission": { + "name": "permission", + "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": {} + }, + "role_permission": { + "name": "role_permission", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "tableTo": "role", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permission_permission_id_permission_id_fk": { + "name": "role_permission_permission_id_permission_id_fk", + "tableFrom": "role_permission", + "tableTo": "permission", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permission_role_id_permission_id_pk": { + "columns": [ + "role_id", + "permission_id" + ], + "name": "role_permission_role_id_permission_id_pk" + } + }, + "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": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..1573082 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,543 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1cde0c8f-dffe-4a01-bc08-678dc5879a13", + "prevId": "f54d0fb3-1aba-4100-89b8-6aaa32d52eba", + "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": {} + }, + "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": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e87a412..fcb8966 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1734454698466, "tag": "0003_dashing_dust", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1735229458794, + "tag": "0004_panoramic_hedge_knight", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1735273566991, + "tag": "0005_tricky_mathemanic", + "breakpoints": true } ] } \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index 889310d..d79376e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,22 +1,47 @@ import { auth } from "@/lib/auth" import { NextResponse } from "next/server" +import { PERMISSIONS } from "@/lib/permissions" +import { checkPermission } from "@/lib/auth" +import { Permission } from "@/lib/permissions" -export async function middleware() { +const API_PERMISSIONS: Record = { + '/api/emails': PERMISSIONS.MANAGE_EMAIL, + '/api/webhook': PERMISSIONS.MANAGE_WEBHOOK, + '/api/roles/promote': PERMISSIONS.PROMOTE_USER, +} + +export async function middleware(request: Request) { const session = await auth() + const pathname = new URL(request.url).pathname if (!session?.user) { return NextResponse.json( - { error: "Unauthorized" }, + { error: "未授权" }, { status: 401 } ) } + for (const [route, permission] of Object.entries(API_PERMISSIONS)) { + if (pathname.startsWith(route)) { + const hasAccess = await checkPermission(permission) + + if (!hasAccess) { + return NextResponse.json( + { error: "权限不足" }, + { status: 403 } + ) + } + break + } + } + return NextResponse.next() } export const config = { matcher: [ - "/api/emails/:path*", - "/api/webhook/:path*", + '/api/emails/:path*', + '/api/webhook/:path*', + '/api/roles/:path*', ] } \ No newline at end of file diff --git a/types.d.ts b/types.d.ts index 07ad719..2426752 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,5 +1,6 @@ /// + declare global { interface CloudflareEnv { DB: D1Database; @@ -8,4 +9,14 @@ declare global { type Env = CloudflareEnv } +declare module "next-auth" { + interface User { + roles?: { name: string }[] + } + + interface Session { + user: User + } +} + export type { Env } \ No newline at end of file