初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+343
View File
@@ -0,0 +1,343 @@
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
import { and, eq, sql } from 'drizzle-orm'
import crypto from 'crypto'
import { ApiLogService } from '~~/server/services/apiLogService'
import {
API_ERROR_CODES,
API_ERROR_MESSAGES,
API_KEY_CONSTANTS,
HTTP_STATUS
} from '~~/server/config/constants'
import { openApiCache } from '~~/server/utils/open-api-cache'
import { getBeijingTime } from '~/utils/timeUtils'
import { getIPBlockRemainingTime, isIPBlocked } from '~~/server/services/securityService'
import { getClientIP } from '~~/server/utils/ip-utils'
/**
* 记录API访问日志
*/
async function logApiAccess(
apiKeyId: number,
method: string,
endpoint: string,
statusCode: number,
responseTimeMs: number,
ipAddress: string,
userAgent: string,
requestBody?: string,
responseBody?: any,
errorMessage?: string
) {
try {
// 写入数据库日志
await ApiLogService.logAccess({
apiKeyId,
endpoint,
method,
ipAddress,
userAgent,
statusCode,
responseTimeMs,
requestBody,
responseBody: responseBody ? openApiCache.truncateResponseBody(responseBody) : undefined,
errorMessage
})
} catch (error) {
console.error('[API Auth Middleware] 记录API访问日志失败:', error)
}
}
/**
* API Key认证中间件
* 处理开放API的认证和权限验证
*/
export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
const pathname = url.pathname
// 只处理开放API路由 (/api/open/*)
if (!pathname.startsWith('/api/open/')) {
return
}
console.log(`[API Auth Middleware] 开始处理开放API请求: ${pathname}`)
const startTime = Date.now()
const method = getMethod(event)
const userAgent = getHeader(event, 'user-agent') || ''
// 获取客户端真实IP地址
const ipAddress = getClientIP(event)
// 检查IP是否被限制
if (isIPBlocked(ipAddress)) {
const remainingTime = getIPBlockRemainingTime(ipAddress)
await ApiLogService.logAccess({
apiKeyId: null,
endpoint: pathname,
method,
ipAddress,
userAgent,
statusCode: 423,
responseTimeMs: Date.now() - startTime,
errorMessage: `IP地址已被限制访问,剩余时间: ${remainingTime}分钟`
})
return sendError(
event,
createError({
statusCode: 423,
message: `您的IP地址已被限制访问,请在 ${remainingTime} 分钟后重试`
})
)
}
// 获取API Key
const apiKey = getHeader(event, 'x-api-key')
console.log(
`[API Auth Middleware] 获取到的API Key: ${apiKey ? apiKey.substring(0, 10) + '...' : 'null'}`
)
if (!apiKey) {
console.log(`[API Auth Middleware] API Key缺失`)
await ApiLogService.logAccess({
apiKeyId: null,
endpoint: pathname,
method,
ipAddress,
userAgent,
statusCode: 401,
responseTimeMs: Date.now() - startTime
})
return sendError(
event,
createError({
statusCode: HTTP_STATUS.UNAUTHORIZED,
message: API_ERROR_MESSAGES[API_ERROR_CODES.MISSING_API_KEY]
})
)
}
try {
// 验证API Key格式 (应该是 vhub_xxxxxxxxxxxxxxxx 格式)
console.log(
`[API Auth Middleware] 验证API Key格式: 长度=${apiKey.length}, 前缀=${apiKey.startsWith('vhub_')}`
)
if (
!apiKey.startsWith(API_KEY_CONSTANTS.PREFIX) ||
apiKey.length !== API_KEY_CONSTANTS.TOTAL_LENGTH
) {
console.log(`[API Auth Middleware] API Key格式无效`)
throw new Error(API_ERROR_MESSAGES[API_ERROR_CODES.INVALID_API_KEY_FORMAT])
}
// 提取前缀和哈希API Key
const keyPrefix = apiKey.substring(0, API_KEY_CONSTANTS.PREFIX_LENGTH)
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex')
console.log(`[API Auth Middleware] 查询API Key哈希: ${keyHash.substring(0, 10)}...`)
// 查询API Key信息
const apiKeyResult = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
isActive: apiKeys.isActive,
expiresAt: apiKeys.expiresAt,
usageCount: apiKeys.usageCount
})
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.keyPrefix, keyPrefix)))
.limit(1)
console.log(
`[API Auth Middleware] 数据库查询结果: ${apiKeyResult.length > 0 ? '找到记录' : '未找到记录'}`
)
const apiKeyRecord = apiKeyResult[0]
if (!apiKeyRecord) {
console.log(`[API Auth Middleware] API Key未找到或未激活`)
await ApiLogService.logAccess({
apiKeyId: null,
endpoint: pathname,
method,
ipAddress,
userAgent,
statusCode: HTTP_STATUS.UNAUTHORIZED,
responseTimeMs: Date.now() - startTime,
errorMessage: API_ERROR_MESSAGES[API_ERROR_CODES.INVALID_API_KEY]
})
throw new Error(API_ERROR_MESSAGES[API_ERROR_CODES.INVALID_API_KEY])
}
console.log(
`[API Auth Middleware] API Key记录: ID=${apiKeyRecord.id}, 名称=${apiKeyRecord.name}, 过期时间=${apiKeyRecord.expiresAt}`
)
// 检查API Key是否激活
if (!apiKeyRecord.isActive) {
await ApiLogService.logAccess({
apiKeyId: apiKeyRecord.id,
endpoint: pathname,
method,
ipAddress,
userAgent,
statusCode: HTTP_STATUS.FORBIDDEN,
responseTimeMs: Date.now() - startTime,
errorMessage: API_ERROR_MESSAGES[API_ERROR_CODES.API_KEY_DISABLED]
})
throw new Error(API_ERROR_MESSAGES[API_ERROR_CODES.API_KEY_DISABLED])
}
// 检查API Key是否过期
if (apiKeyRecord.expiresAt && getBeijingTime() > apiKeyRecord.expiresAt) {
console.log(`[API Auth Middleware] API Key已过期`)
await ApiLogService.logAccess({
apiKeyId: apiKeyRecord.id,
endpoint: pathname,
method,
ipAddress,
userAgent,
statusCode: HTTP_STATUS.FORBIDDEN,
responseTimeMs: Date.now() - startTime,
errorMessage: API_ERROR_MESSAGES[API_ERROR_CODES.API_KEY_EXPIRED]
})
throw new Error(API_ERROR_MESSAGES[API_ERROR_CODES.API_KEY_EXPIRED])
}
// 检查权限
const requiredPermission = getRequiredPermission(pathname, method)
console.log(`[API Auth Middleware] 所需权限: ${requiredPermission}`)
if (requiredPermission) {
const permissionResult = await db
.select()
.from(apiKeyPermissions)
.where(
and(
eq(apiKeyPermissions.apiKeyId, apiKeyRecord.id),
eq(apiKeyPermissions.permission, requiredPermission)
)
)
.limit(1)
console.log(
`[API Auth Middleware] 权限检查结果: ${permissionResult.length > 0 ? '通过' : '失败'}`
)
if (permissionResult.length === 0) {
console.log(`[API Auth Middleware] 缺少必需权限: ${requiredPermission}`)
await ApiLogService.logAccess({
apiKeyId: apiKeyRecord.id,
endpoint: pathname,
method,
ipAddress,
userAgent,
statusCode: HTTP_STATUS.FORBIDDEN,
responseTimeMs: Date.now() - startTime,
errorMessage: `${API_ERROR_MESSAGES[API_ERROR_CODES.INSUFFICIENT_PERMISSIONS]}: ${requiredPermission}`
})
throw new Error(
`${API_ERROR_MESSAGES[API_ERROR_CODES.INSUFFICIENT_PERMISSIONS]}. Required: ${requiredPermission}`
)
}
}
// 使用原子操作更新API Key使用统计
console.log(`[API Auth Middleware] 开始原子更新API Key使用统计`)
await db
.update(apiKeys)
.set({
lastUsedAt: new Date(),
usageCount: sql`${apiKeys.usageCount} + 1`,
updatedAt: new Date()
})
.where(eq(apiKeys.id, apiKeyRecord.id))
console.log(`[API Auth Middleware] API Key使用统计原子更新完成`)
// 记录成功的API访问
console.log(`[API Auth Middleware] 开始记录API访问日志`)
try {
await logApiAccess(
apiKeyRecord.id,
method,
pathname,
HTTP_STATUS.OK,
Date.now() - startTime,
ipAddress,
getHeader(event, 'user-agent') || 'Unknown'
)
console.log(`[API Auth Middleware] API访问日志记录完成`)
} catch (error) {
console.error(`[API Auth Middleware] 记录API访问日志失败:`, error)
}
console.log(`[API Auth Middleware] 验证成功,继续处理请求`)
// 将API Key信息添加到事件上下文中,供后续处理使用
event.context.apiKey = apiKeyRecord
// 记录成功的API访问(在响应后记录)
event.context.logApiAccess = async (
statusCode: number,
responseBody?: any,
errorMessage?: string
) => {
const requestBody = method !== 'GET' ? await readBody(event).catch(() => null) : null
await logApiAccess(
apiKeyRecord.id,
method,
pathname,
statusCode,
Date.now() - startTime,
ipAddress,
userAgent,
requestBody ? JSON.stringify(requestBody) : undefined,
responseBody,
errorMessage
)
}
// 验证成功,让请求继续到API端点
return
} catch (error: any) {
const statusCode =
error.message.includes('expired') ||
error.message.includes('disabled') ||
error.message.includes('permissions') ||
error.message.includes('IP address')
? HTTP_STATUS.FORBIDDEN
: HTTP_STATUS.UNAUTHORIZED
return sendError(
event,
createError({
statusCode,
message: error.message
})
)
}
})
/**
* 根据路径和方法获取所需权限
*/
function getRequiredPermission(pathname: string, method: string): string | null {
if (pathname.startsWith('/api/open/schedules')) {
return 'schedules:read'
}
if (pathname.startsWith('/api/open/songs')) {
return 'songs:read'
}
return null
}
+84
View File
@@ -0,0 +1,84 @@
export default defineEventHandler((event) => {
const pathname = getRequestURL(event).pathname
// 只处理特定的内部API路由,防止站外调用
const isProtectedApi = pathname.startsWith('/api/api-enhanced/netease') ||
pathname.startsWith('/api/native-api')
if (!isProtectedApi) {
return
}
// CORS 限制:禁止站外网站调用
const origin = getHeader(event, 'origin')
const referer = getHeader(event, 'referer')
const secFetchSite = getHeader(event, 'sec-fetch-site')
const sourceUrl = origin || referer
if (sourceUrl) {
try {
const url = new URL(sourceUrl)
const extractHostname = (value: string) => {
const normalizedValue = value.includes('://') ? value : `http://${value}`
return new URL(normalizedValue).hostname
}
// 获取当前请求的 hostname,优先使用配置中可信的主机名,防止 x-forwarded-host 伪造攻击
const config = useRuntimeConfig(event)
const configuredHost = config.public?.host
let requestHostname: string
if (configuredHost) {
requestHostname = extractHostname(configuredHost)
} else {
// 只有在未配置公网主机名时,才回退到 host 请求头
const hostHeader = getHeader(event, 'host')
if (hostHeader) {
requestHostname = extractHostname(hostHeader)
} else {
// 缺少 Host 请求头,这是一个异常情况,应该拒绝请求
throw createError({ statusCode: 400, message: 'Bad Request: 缺少Host请求头' })
}
}
const isLocalhost = (h: string) => h === 'localhost' || h === '127.0.0.1' || h === '[::1]'
// 如果来源域与当前域不一致,则拒绝请求
// 注意:允许 localhost 和 127.0.0.1 之间的互访
if (url.hostname !== requestHostname && !(isLocalhost(url.hostname) && isLocalhost(requestHostname))) {
console.warn(`[CORS Middleware] 拦截跨域请求: 来源 ${url.hostname}, 期望 ${requestHostname}, 路径 ${pathname}`)
throw createError({
statusCode: 403,
message: 'Forbidden: 内部API不允许跨域请求'
})
}
} catch (e: unknown) {
// 如果是我们主动抛出的 HTTP 错误,则重新抛出
if (
typeof e === 'object' &&
e !== null &&
'statusCode' in e &&
typeof e.statusCode === 'number'
) {
throw e
}
// 对于其他错误(如无效的URL),这是一个错误的请求
console.warn(`[CORS Middleware] 无效的来源 URL: ${sourceUrl}`, e)
throw createError({
statusCode: 400,
message: 'Bad Request: Origin或Referer头无效'
})
}
} else if (secFetchSite === 'same-origin') {
// 允许没有 Origin/Referer 但明确标记为同源的请求
return
} else {
// 拦截没有来源信息的受保护API请求
console.warn(`[CORS Middleware] 拦截无Origin/Referer头的请求: 路径 ${pathname}, sec-fetch-site: ${secFetchSite || 'none'}`)
throw createError({
statusCode: 403,
message: 'Forbidden: 访问此API必须提供Origin或Referer头'
})
}
})
+232
View File
@@ -0,0 +1,232 @@
import { JWTEnhanced } from '../utils/jwt-enhanced'
import { db, users } from '~/drizzle/db'
import { eq } from 'drizzle-orm'
import { isUserBlocked, getUserBlockRemainingTime } from '../services/securityService'
export default defineEventHandler(async (event) => {
// 清除用户上下文
if (event.context.user) {
delete event.context.user
}
const url = getRequestURL(event)
const pathname = url.pathname
// 跳过非API路由
if (!pathname.startsWith('/api/')) {
return
}
// 公共API路径
const publicApiPaths = [
'/api/auth/login',
'/api/auth/register',
'/api/auth/bind', // 账号绑定
'/api/auth/verify', // verify端点自行处理token验证
'/api/semesters/current',
'/api/play-times',
'/api/schedules/public',
'/api/songs/count',
'/api/songs/public',
'/api/site-config',
'/api/proxy/', // 代理API路径,用于图片代理等功能
'/api/bilibili/', // 哔哩哔哩相关API
'/api/api-enhanced/', // 网易云音乐API代理路径
'/api/native-api/', // Native Music 集成API
'/api/system/location', // 系统位置检测API
'/api/open/', // 开放API路径,由api-auth中间件处理认证
'/api/auth/webauthn/login', // WebAuthn 登录接口
'/api/music/state', // 音乐状态同步
'/api/music/websocket' // WebSocket 连接
]
// 公共路径跳过认证检查
if (publicApiPaths.some((path) => pathname.startsWith(path))) {
return
}
// 动态判断 OAuth 路径
// 允许 /api/auth/[provider] 和 /api/auth/[provider]/callback
// 但排除已知的受保护/特定 Auth 端点
if (pathname.startsWith('/api/auth/')) {
const segments = pathname.split('/')
// segments: ['', 'api', 'auth', 'provider', 'callback'?]
const provider = segments[3]
// 已知的受保护或非OAuth的Auth端点
const nonOAuthEndpoints = [
'identities',
'logout',
'change-password',
'set-initial-password',
'unbind',
// login, bind, verify 已经在 publicApiPaths 中处理,这里列出是为了完整性或防止意外匹配
'login',
'bind',
'verify',
'webauthn'
]
if (provider && !nonOAuthEndpoints.includes(provider)) {
// 这是一个 OAuth 提供商路径 (例如 /api/auth/casdoor)
return
}
}
// 从请求头或cookie获取token
let token: string | null = null
const authHeader = getRequestHeader(event, 'authorization')
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7)
}
if (!token) {
token = getCookie(event, 'auth-token') || null
}
// 受保护路由缺少token时返回401错误
if (!token) {
return sendError(
event,
createError({
statusCode: 401,
message: '未授权访问:缺少有效的认证信息'
})
)
}
try {
// 验证token并自动续期
const { valid, payload, newToken } = JWTEnhanced.verifyAndRefresh(token)
if (!valid || !payload) {
throw new Error('Token无效')
}
// 如果生成了新token,更新cookie
if (newToken) {
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
setCookie(event, 'auth-token', newToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7天
path: '/'
})
}
const decoded = payload
const userResult = await db
.select({
id: users.id,
username: users.username,
name: users.name,
grade: users.grade,
class: users.class,
role: users.role,
status: users.status,
passwordChangedAt: users.passwordChangedAt
})
.from(users)
.where(eq(users.id, decoded.userId))
.limit(1)
const user = userResult[0] || null
// 用户不存在或状态异常时token无效
if (!user || user.status !== 'active') {
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
setCookie(event, 'auth-token', '', {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 0,
path: '/'
})
const errorMessage = !user
? '用户不存在,请重新登录'
: user.status === 'withdrawn'
? '该账号已注销'
: user.status === 'graduate'
? '该账号已毕业,限制访问'
: '该账号已被禁用'
return sendError(
event,
createError({
statusCode: 401,
message: errorMessage
})
)
}
// 检查token是否在密码修改之前签发(强制旧token失效)
if (user.passwordChangedAt && decoded.iat) {
const passwordChangedTime = Math.floor(new Date(user.passwordChangedAt).getTime() / 1000)
if (decoded.iat < passwordChangedTime) {
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
setCookie(event, 'auth-token', '', {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 0,
path: '/'
})
return sendError(
event,
createError({
statusCode: 401,
message: '密码已修改,请重新登录',
data: { invalidToken: true, passwordChanged: true }
})
)
}
}
event.context.user = user
if (isUserBlocked(user.id)) {
delete event.context.user
const remaining = getUserBlockRemainingTime(user.id)
return sendError(
event,
createError({
statusCode: 401,
message: `账户处于风险控制期,请在 ${remaining} 分钟后重试`
})
)
}
// 检查管理员专用路由
if (
pathname.startsWith('/api/admin') &&
!['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)
) {
return sendError(
event,
createError({
statusCode: 403,
message: '需要管理员权限'
})
)
}
} catch (error: any) {
// 处理JWT验证错误
return sendError(
event,
createError({
statusCode: 401,
message: `认证失败: ${error.message}`,
data: { invalidToken: true }
})
)
}
})