diff --git a/.env.example b/.env.example index a3428fc..873ff68 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ UPLOAD_SUBDIR=upload NITRO_PORT=3399 SCHEDULER_MAX_CONCURRENCY=5 SCHEDULER_LOG_RETENTION_DAYS=30 +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_PASSWORD=123456qaz GITHUB_CLIENT_ID=your_github_client_id GITHUB_CLIENT_SECRET=your_github_client_secret APP_URL=http://localhost:3399 \ No newline at end of file diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts index 1af5d8f..4500ed5 100644 --- a/app/middleware/auth.global.ts +++ b/app/middleware/auth.global.ts @@ -5,6 +5,7 @@ import { normalizeSafeRedirect, } from "@/utils/auth-routes"; import { useAuthSession } from "../composables/useAuthSession"; +import { FRONTEND_LOGIN_PATH } from "common/config" export default defineNuxtRouteMiddleware(async (to) => { if(to.path.startsWith("/__nuxt_error")) { @@ -27,7 +28,7 @@ export default defineNuxtRouteMiddleware(async (to) => { if (!isLoggedIn && !isPublicRoute(currentPath)) { return navigateTo({ - path: "/auth", + path: FRONTEND_LOGIN_PATH, query: { redirect: currentFullPath }, }); } diff --git a/app/pages/admin.vue b/app/pages/admin.vue index 10099ed..5a3936b 100644 --- a/app/pages/admin.vue +++ b/app/pages/admin.vue @@ -22,8 +22,6 @@ const adminNav: NavItem[] = [ icon: 'lucide:settings', children: [ { label: '用户管理', to: '/admin/users' }, - { label: '角色管理', to: '/admin/roles' }, - { label: '操作日志', to: '/admin/logs' }, ] }, ] diff --git a/app/pages/admin/users/index.vue b/app/pages/admin/users/index.vue new file mode 100644 index 0000000..7f7bc23 --- /dev/null +++ b/app/pages/admin/users/index.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/app/pages/photo/index.vue b/app/pages/photo/index.vue index 9b1d2a6..99f303e 100644 --- a/app/pages/photo/index.vue +++ b/app/pages/photo/index.vue @@ -10,7 +10,6 @@ interface CardItem { aspectRatio: number } -const { capture, play } = useFlipAnimation() const masonryEl = ref(null) const allItems = ref([]) @@ -80,14 +79,8 @@ function updateColumns() { else n = 5 if (n !== columnCount.value) { - if (masonryEl.value) capture(masonryEl.value, '.card-reveal') columnCount.value = n distributeAll() - nextTick(() => { - if (masonryEl.value) { - play(masonryEl.value, '.card-reveal', { duration: 420 }) - } - }) } } diff --git a/app/utils/auth-routes.ts b/app/utils/auth-routes.ts index 7d14bc7..3128324 100644 --- a/app/utils/auth-routes.ts +++ b/app/utils/auth-routes.ts @@ -1,17 +1,10 @@ -const PUBLIC_ROUTE_EXACT = new Set(["/", "/auth/login", "/auth/register"]); -const GUEST_ONLY_ROUTE_EXACT = new Set(["/auth/login", "/auth/register"]); +import { FRONTEND_PAGE_ALLOWLIST, FRONTEND_PAGE_GUEST_ONLY } from "common/config" +import { normalizePath } from "common/utils/path" + const PUBLIC_ROUTE_PREFIXES: string[] = []; export const DEFAULT_AUTHENTICATED_LANDING_PATH = "/"; -function normalizePath(path: string) { - const trimmed = path.trim(); - if (!trimmed) { - return "/"; - } - return trimmed.length > 1 ? trimmed.replace(/\/+$/, "") : trimmed; -} - function matchesExactOrPrefix(path: string, exact: Set, prefixes: string[]) { const normalized = normalizePath(path); if (exact.has(normalized)) { @@ -21,11 +14,11 @@ function matchesExactOrPrefix(path: string, exact: Set, prefixes: string } export function isPublicRoute(path: string) { - return matchesExactOrPrefix(path, PUBLIC_ROUTE_EXACT, PUBLIC_ROUTE_PREFIXES); + return matchesExactOrPrefix(path, FRONTEND_PAGE_ALLOWLIST, PUBLIC_ROUTE_PREFIXES); } export function isGuestOnlyRoute(path: string) { - return GUEST_ONLY_ROUTE_EXACT.has(normalizePath(path)); + return FRONTEND_PAGE_GUEST_ONLY.has(normalizePath(path)); } export function normalizeSafeRedirect( diff --git a/bun.lock b/bun.lock index d3ea3e6..79cd509 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@nuxt/icon": "2.2.2", "bcryptjs": "3.0.3", "cache": "workspace:*", + "common": "workspace:*", "croner": "10.0.1", "dotenv": "17.4.1", "drizzle-orm": "0.45.2", @@ -20,7 +21,6 @@ "mime": "4.1.0", "multer": "2.1.1", "nuxt": "4.4.5", - "oauth": "workspace:*", "svg-captcha": "1.4.0", "tailwindcss": "4.3.0", "ufo": "1.6.3", @@ -49,22 +49,15 @@ "@types/node": "20.0.0", }, }, + "packages/common": { + "name": "common", + }, "packages/drizzle-pkg": { "name": "drizzle-pkg", }, "packages/logger": { "name": "logger", }, - "packages/oauth": { - "name": "oauth", - "version": "0.1.0", - "dependencies": { - "zod": "4.3.6", - }, - "devDependencies": { - "@types/node": "20.0.0", - }, - }, "packages/tsconfig": { "name": "tsconfig", }, @@ -750,6 +743,8 @@ "commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "common": ["common@workspace:packages/common"], + "commondir": ["commondir@1.0.1", "https://registry.npmmirror.com/commondir/-/commondir-1.0.1.tgz", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], "compatx": ["compatx@0.2.0", "https://registry.npmmirror.com/compatx/-/compatx-0.2.0.tgz", {}, "sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA=="], @@ -1206,8 +1201,6 @@ "nypm": ["nypm@0.6.6", "https://registry.npmmirror.com/nypm/-/nypm-0.6.6.tgz", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="], - "oauth": ["oauth@workspace:packages/oauth"], - "obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "ofetch": ["ofetch@1.5.1", "https://registry.npmmirror.com/ofetch/-/ofetch-1.5.1.tgz", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], @@ -1854,8 +1847,6 @@ "nuxt/vue-router": ["vue-router@5.0.7", "https://registry.npmmirror.com/vue-router/-/vue-router-5.0.7.tgz", { "dependencies": { "@babel/generator": "^8.0.0-rc.4", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.1.1", "ast-walker-scope": "^0.8.3", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.15", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.8.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34", "pinia": "^3.0.4", "vue": "^3.5.34" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia"] }, "sha512-dqfk8kvRbCutmCOCj/XLDqDEYxc1wBdAOGLuVy5M93ifYMsBd5fIjfaPN4tQAbxr5IprdBDIox1gr4wYyOx/SA=="], - "oauth/@types/node": ["@types/node@20.0.0", "https://registry.npmmirror.com/@types/node/-/node-20.0.0.tgz", {}, "sha512-cD2uPTDnQQCVpmRefonO98/PPijuOnnEy5oytWJFPY1N9aJCz2wJ5kSGWO+zJoed2cY2JxQh6yBuUq4vIn61hw=="], - "postcss-colormin/postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "postcss-convert-values/postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], diff --git a/nuxt.config.ts b/nuxt.config.ts index e552119..bfba82a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -18,11 +18,25 @@ export default defineNuxtConfig({ devtools: { enabled: true }, vite: { + optimizeDeps: { + include: [ + 'vue3-toastify', + ] + }, plugins: [ tailwindcss(), ] }, - + typescript: { + tsConfig: { + compilerOptions: { + ignoreDeprecations: "6.0", + paths: { + 'common': ['./packages/common'] + }, + } + } + }, nitro: { typescript: { tsConfig: { @@ -31,8 +45,10 @@ export default defineNuxtConfig({ resolvePackageJsonImports: true, ignoreDeprecations: "6.0", paths: { - 'drizzle-pkg': ['./packages/drizzle-pkg/lib'], - 'logger': ['./packages/logger/lib'] + 'drizzle-pkg': ['./packages/drizzle-pkg'], + 'cache': ['./packages/cache'], + 'common': ['./packages/common'], + 'logger': ['./packages/logger'] }, } }, diff --git a/package.json b/package.json index 17d3a5d..73a9244 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@nuxt/icon": "2.2.2", "bcryptjs": "3.0.3", "cache": "workspace:*", + "common": "workspace:*", "croner": "10.0.1", "dotenv": "17.4.1", "drizzle-orm": "0.45.2", diff --git a/packages/common/config/index.ts b/packages/common/config/index.ts new file mode 100644 index 0000000..c342971 --- /dev/null +++ b/packages/common/config/index.ts @@ -0,0 +1,23 @@ +export type RouteRule = { + path: string; + methods?: string[]; +}; + +export const API_ALLOWLIST: RouteRule[] = [ + { path: "/api/auth/captcha", methods: ["GET"] }, + { path: "/api/auth/login", methods: ["POST"] }, + { path: "/api/auth/register", methods: ["POST"] }, + /** 访客可读:无 Cookie 时不查库,用于客户端与 SSR 会话对齐 */ + { path: "/api/auth/session", methods: ["GET"] }, + { path: "/api/config/global", methods: ["GET"] }, + { path: "/api/auth/oauth/:provider/callback", methods: ["GET"] }, + { path: "/api/auth/oauth/:provider/authorize", methods: ["GET"] }, + /** 卡片相关:ID 在路径参数中 */ + { path: "/api/cards/:id", methods: ["GET"] }, + { path: "/api/pic/random", methods: ["GET"] }, +]; + +export const FRONTEND_LOGIN_PATH = "/auth/login" +export const FRONTEND_REGISTER_PATH = "/auth/register" +export const FRONTEND_PAGE_ALLOWLIST = new Set(["/", FRONTEND_LOGIN_PATH, FRONTEND_REGISTER_PATH]) +export const FRONTEND_PAGE_GUEST_ONLY = new Set([FRONTEND_LOGIN_PATH, FRONTEND_REGISTER_PATH]) diff --git a/packages/common/package.json b/packages/common/package.json new file mode 100644 index 0000000..ea471c6 --- /dev/null +++ b/packages/common/package.json @@ -0,0 +1,4 @@ +{ + "name": "common", + "sideEffects": false +} \ No newline at end of file diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json new file mode 100644 index 0000000..b473b69 --- /dev/null +++ b/packages/common/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "tsconfig/tsconfig.json" +} \ No newline at end of file diff --git a/packages/common/utils/path.ts b/packages/common/utils/path.ts new file mode 100644 index 0000000..c34a8e1 --- /dev/null +++ b/packages/common/utils/path.ts @@ -0,0 +1,24 @@ +/** + * 将路由路径转换为正则表达式,支持 : + * - `:id` 匹配单个路径段 + * - `:id+` 匹配多个路径段 + * - `:id?` 可选 + */ +export function pathToRegexp(pattern: string): RegExp { + // 只匹配 :param 部分(到下一个 / 或字符串结尾),不包括后续路径段 + const escaped = pattern.replace(/:[^/]+(\?)?/g, (match) => { + if (match.endsWith("?")) { + return "([^/]*)"; + } + return "([^/]+)"; + }); + return new RegExp(`^${escaped}$`); +} + +export function normalizePath(path: string) { + const trimmed = path.trim(); + if (!trimmed) { + return "/"; + } + return trimmed.length > 1 ? trimmed.replace(/\/+$/, "") : trimmed; +} diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index a2946a0..6499ddc 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ diff --git a/server/api/auth/oauth/[provider]/callback.get.ts b/server/api/auth/oauth/[provider]/callback.get.ts index 5207fa0..bba13d4 100644 --- a/server/api/auth/oauth/[provider]/callback.get.ts +++ b/server/api/auth/oauth/[provider]/callback.get.ts @@ -1,6 +1,7 @@ import { oauthManager } from '#server/service/oauth/oauth-manager'; import { setSessionCookie } from '#server/service/auth/cookie'; import { OAuthError } from '#server/service/oauth/oauth-error'; +import { FRONTEND_LOGIN_PATH } from "common/config" export default defineWrappedResponseHandler(async (event) => { const providerName = getRouterParam(event, 'provider'); @@ -9,7 +10,7 @@ export default defineWrappedResponseHandler(async (event) => { const { code, state } = query as { code?: string; state?: string }; if (!code || !state) { - return sendRedirect(event, '/auth/login?oauth_error=missing_params'); + return sendRedirect(event, `${FRONTEND_LOGIN_PATH}?oauth_error=missing_params`); } try { @@ -19,9 +20,9 @@ export default defineWrappedResponseHandler(async (event) => { setSessionCookie(event, result.sessionId); } - return sendRedirect(event, '/auth/login?oauth_success=1'); + return sendRedirect(event, `${FRONTEND_LOGIN_PATH}??oauth_success=1`); } catch (error) { const errorCode = error instanceof OAuthError ? error.code : 'OAUTH_UNKNOWN'; - return sendRedirect(event, `/auth/login?oauth_error=${errorCode}`); + return sendRedirect(event, `${FRONTEND_LOGIN_PATH}??oauth_error=${errorCode}`); } }); \ No newline at end of file diff --git a/server/middleware/02.auth-guard.ts b/server/middleware/02.auth-guard.ts index 1b840e0..c90ef93 100644 --- a/server/middleware/02.auth-guard.ts +++ b/server/middleware/02.auth-guard.ts @@ -1,6 +1,8 @@ import { UNAUTHORIZED_MESSAGE } from "#server/constants/auth"; import { isAllowlistedApiPath, isFrontendPageAllowed } from "#server/utils/auth-api-routes"; import { getCurrentUser } from "#server/utils/context"; +import { FRONTEND_LOGIN_PATH, FRONTEND_PAGE_GUEST_ONLY } from "common/config" +import { normalizePath } from "common/utils/path"; export default eventHandler(async (event) => { const path = event.path; @@ -35,11 +37,11 @@ export default eventHandler(async (event) => { const user = await getCurrentUser(event); if (!user) { // 未登录且页面不在白名单,重定向到登录页 - return sendRedirect(event, "/auth/login", 302); + return sendRedirect(event, FRONTEND_LOGIN_PATH, 302); } // 已登录用户访问登录/注册页面,重定向到首页 - if (path.startsWith("/auth/login") || path.startsWith("/auth/register")) { + if (FRONTEND_PAGE_GUEST_ONLY.has(normalizePath(path))) { return sendRedirect(event, "/", 302); } }); \ No newline at end of file diff --git a/server/service/oauth/oauth-manager.ts b/server/service/oauth/oauth-manager.ts index 1a4bb14..2554e02 100644 --- a/server/service/oauth/oauth-manager.ts +++ b/server/service/oauth/oauth-manager.ts @@ -6,6 +6,7 @@ import { OAuthStateStore, oauthStateStore } from './oauth-state-store'; import { OAuthError, OAuthErrorCodes } from './oauth-error'; import { registerUser, createSession } from '#server/service/auth'; import type { MinimalUser } from '#server/service/auth'; +import { FRONTEND_LOGIN_PATH } from "common/config" const logger = log4js.getLogger("OAUTH"); @@ -28,6 +29,14 @@ export type OAuthCallbackResult = { expiresAt?: Date; }; +export type FieldMapping = { + provider?: string; + providerUserId: string; + username?: string; + email?: string; + avatar?: string; +}; + export type OAuthProviderConfig = { name: string; icon: string; @@ -38,13 +47,7 @@ export type OAuthProviderConfig = { userInfoUrl: string; scopes: string[]; redirectUri: string; - mapUserInfo: (raw: Record) => { - provider: string; - providerUserId: string; - username?: string; - email?: string; - avatar?: string; - }; + userInfoMapping: FieldMapping; }; const providers: Record = { @@ -58,14 +61,11 @@ const providers: Record = { userInfoUrl: 'https://api.github.com/user', scopes: ['read:user', 'user:email'], redirectUri: `${process.env.APP_URL}/api/auth/oauth/github/callback`, - mapUserInfo(raw: Record) { - return { - provider: 'github', - providerUserId: String(raw.id ?? ''), - username: String(raw.login ?? ''), - email: String(raw.email ?? ''), - avatar: String(raw.avatar_url ?? ''), - }; + userInfoMapping: { + providerUserId: 'id', + username: 'login', + email: 'email', + avatar: 'avatar_url', }, }, }; @@ -89,7 +89,7 @@ export class OAuthManager { const redirectUri = userId ? `/profile?bind_success=1` - : `/auth/login?oauth_success=1`; + : `${FRONTEND_LOGIN_PATH}?oauth_success=1`; const { state } = this.stateStore.generate(providerName, redirectUri, userId); @@ -283,6 +283,21 @@ export class OAuthManager { }>; } + private mapUserInfo(raw: Record, mapping: FieldMapping) { + const getValue = (path: string) => { + const value = (raw as Record)[path]; + return value != null ? String(value) : undefined; + }; + + return { + provider: mapping.provider || '', + providerUserId: getValue(mapping.providerUserId) || '', + username: mapping.username ? getValue(mapping.username) : undefined, + email: mapping.email ? getValue(mapping.email) : undefined, + avatar: mapping.avatar ? getValue(mapping.avatar) : undefined, + }; + } + private async getUserInfo( provider: OAuthProviderConfig, accessToken: string @@ -299,7 +314,7 @@ export class OAuthManager { } const raw = await response.json() as Record; - return provider.mapUserInfo(raw); + return this.mapUserInfo(raw, provider.userInfoMapping); } private async createOAuthAccount(data: { diff --git a/server/utils/auth-api-routes.ts b/server/utils/auth-api-routes.ts index d3ebc30..db2b457 100644 --- a/server/utils/auth-api-routes.ts +++ b/server/utils/auth-api-routes.ts @@ -1,21 +1,5 @@ -type RouteRule = { - path: string; - methods?: string[]; -}; - -const API_ALLOWLIST: RouteRule[] = [ - { path: "/api/auth/captcha", methods: ["GET"] }, - { path: "/api/auth/login", methods: ["POST"] }, - { path: "/api/auth/register", methods: ["POST"] }, - /** 访客可读:无 Cookie 时不查库,用于客户端与 SSR 会话对齐 */ - { path: "/api/auth/session", methods: ["GET"] }, - { path: "/api/config/global", methods: ["GET"] }, - { path: "/api/auth/oauth/:provider/callback", methods: ["GET"] }, - { path: "/api/auth/oauth/:provider/authorize", methods: ["GET"] }, - /** 卡片相关:ID 在路径参数中 */ - { path: "/api/cards/:id", methods: ["GET"] }, - { path: "/api/pic/random", methods: ["GET"] }, -]; +import { API_ALLOWLIST, FRONTEND_PAGE_ALLOWLIST } from "common/config" +import { pathToRegexp } from "common/utils/path" /** 公开 API 以只读为主,需配合服务端校验与限流 */ export function isPublicApiPath(path: string, method?: string) { @@ -29,42 +13,18 @@ export function isPublicApiPath(path: string, method?: string) { return false; } -/** 前端页面白名单:无需登录即可直接访问 */ -const FRONTEND_PAGE_ALLOWLIST: RouteRule[] = [ - { path: "/auth/login" }, - { path: "/auth/register" }, - { path: "/" }, -]; - /** * 检查前端页面是否在白名单中(允许未登录用户直接访问) * 已登录用户访问这些页面会被重定向 */ export function isFrontendPageAllowed(path: string): boolean { const cleanPath = path.split("?")[0]; - return FRONTEND_PAGE_ALLOWLIST.some((rule) => { - const regex = pathToRegexp(rule.path); + return Array.from(FRONTEND_PAGE_ALLOWLIST).some((rule) => { + const regex = pathToRegexp(rule); return regex.test(cleanPath!); }); } -/** - * 将路由路径转换为正则表达式,支持 : - * - `:id` 匹配单个路径段 - * - `:id+` 匹配多个路径段 - * - `:id?` 可选 - */ -function pathToRegexp(pattern: string): RegExp { - // 只匹配 :param 部分(到下一个 / 或字符串结尾),不包括后续路径段 - const escaped = pattern.replace(/:[^/]+(\?)?/g, (match) => { - if (match.endsWith("?")) { - return "([^/]*)"; - } - return "([^/]+)"; - }); - return new RegExp(`^${escaped}$`); -} - export function isAllowlistedApiPath(path: string, method?: string) { if (isPublicApiPath(path, method)) { return true;