feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知) - requireSongAdmin 三角色校验 + createApiError 统一错误包装 - scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤) - submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic - 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '~/utils/password-policy'
|
||||
|
||||
interface AdminPasswordViolation {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function getAdminPasswordViolation(password: unknown): AdminPasswordViolation | null {
|
||||
if (typeof password !== 'string' || !password) {
|
||||
return { code: 'AUTH_NEW_PASSWORD_REQUIRED', message: '新密码不能为空' }
|
||||
}
|
||||
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
return {
|
||||
code: 'AUTH_PASSWORD_TOO_SHORT',
|
||||
message: `密码长度不能少于${PASSWORD_MIN_LENGTH}个字符`
|
||||
}
|
||||
}
|
||||
|
||||
if (password.length > PASSWORD_MAX_LENGTH) {
|
||||
return {
|
||||
code: 'AUTH_PASSWORD_TOO_LONG',
|
||||
message: `密码长度不能超过${PASSWORD_MAX_LENGTH}位`
|
||||
}
|
||||
}
|
||||
|
||||
if (new TextEncoder().encode(password).length > 72) {
|
||||
return {
|
||||
code: 'AUTH_PASSWORD_TOO_MANY_BYTES',
|
||||
message: '密码有效长度不能超过72字节'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createError } from 'h3'
|
||||
|
||||
export function createApiError(
|
||||
statusCode: number,
|
||||
code: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
) {
|
||||
return createError({
|
||||
statusCode,
|
||||
statusMessage: code,
|
||||
message,
|
||||
data: { code, ...(data || {}) }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
|
||||
interface RoutePolicy {
|
||||
path: string
|
||||
methods: readonly HttpMethod[]
|
||||
}
|
||||
|
||||
const GET_METHODS = ['GET', 'HEAD'] as const
|
||||
const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'] as const
|
||||
|
||||
export const PASSWORD_CHANGE_ALLOWED_ROUTES: readonly RoutePolicy[] = [
|
||||
{ path: '/api/auth/verify', methods: GET_METHODS },
|
||||
{ path: '/api/auth/logout', methods: ['POST'] },
|
||||
{ path: '/api/auth/change-password', methods: ['POST'] },
|
||||
{ path: '/api/auth/set-initial-password', methods: ['POST'] },
|
||||
{ path: '/api/auth/2fa/verify', methods: ['POST'] },
|
||||
{ path: '/api/auth/2fa/send-email', methods: ['POST'] },
|
||||
{ path: '/api/site-config', methods: GET_METHODS }
|
||||
]
|
||||
|
||||
export const PUBLIC_API_EXACT_ROUTES: readonly RoutePolicy[] = [
|
||||
{ path: '/api/auth/login', methods: ['POST'] },
|
||||
{ path: '/api/auth/register', methods: ['POST'] },
|
||||
{ path: '/api/auth/email-code', methods: ['POST'] },
|
||||
{ path: '/api/auth/grade-class-options', methods: GET_METHODS },
|
||||
{ path: '/api/auth/oauth-register-options', methods: GET_METHODS },
|
||||
{ path: '/api/auth/bind', methods: ['POST'] },
|
||||
{ path: '/api/auth/oauth-register', methods: ['POST'] },
|
||||
{ path: '/api/auth/2fa/verify', methods: ['POST'] },
|
||||
{ path: '/api/auth/2fa/send-email', methods: ['POST'] },
|
||||
{ path: '/api/auth/forgot-password', methods: ['POST'] },
|
||||
{ path: '/api/auth/reset-password', methods: ['POST'] },
|
||||
{ path: '/api/auth/captcha', methods: GET_METHODS },
|
||||
{ path: '/api/semesters/current', methods: GET_METHODS },
|
||||
{ path: '/api/play-times', methods: GET_METHODS },
|
||||
{ path: '/api/schedules/public', methods: GET_METHODS },
|
||||
{ path: '/api/songs/count', methods: GET_METHODS },
|
||||
{ path: '/api/songs/public', methods: GET_METHODS },
|
||||
{ path: '/api/site-config', methods: GET_METHODS },
|
||||
{ path: '/api/system/location', methods: GET_METHODS },
|
||||
{ path: '/api/auth/webauthn/login', methods: ['POST'] },
|
||||
{ path: '/api/music/resolve-url', methods: ['POST'] },
|
||||
{ path: '/api/music/state', methods: ['POST'] },
|
||||
{ path: '/api/music/websocket', methods: GET_METHODS },
|
||||
{ path: '/api/sys/time', methods: GET_METHODS }
|
||||
]
|
||||
|
||||
export const PUBLIC_API_PREFIX_ROUTES: readonly RoutePolicy[] = [
|
||||
{ path: '/api/proxy/', methods: GET_METHODS },
|
||||
{ path: '/api/api-enhanced/', methods: [...GET_METHODS, ...WRITE_METHODS] },
|
||||
{ path: '/api/native-api/', methods: [...GET_METHODS, 'POST'] },
|
||||
{ path: '/api/open/', methods: [...GET_METHODS, ...WRITE_METHODS] },
|
||||
{ path: '/api/auth/webauthn/login/', methods: ['POST'] }
|
||||
]
|
||||
|
||||
function matchesMethod(policy: RoutePolicy, method: string): boolean {
|
||||
return policy.methods.includes(method.toUpperCase() as HttpMethod)
|
||||
}
|
||||
|
||||
export function isPublicApiPath(pathname: string, method: string): boolean {
|
||||
return (
|
||||
PUBLIC_API_EXACT_ROUTES.some(
|
||||
(policy) => pathname === policy.path && matchesMethod(policy, method)
|
||||
) ||
|
||||
PUBLIC_API_PREFIX_ROUTES.some(
|
||||
(policy) => pathname.startsWith(policy.path) && matchesMethod(policy, method)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function isAllowedDuringPasswordChange(pathname: string, method: string): boolean {
|
||||
return PASSWORD_CHANGE_ALLOWED_ROUTES.some(
|
||||
(policy) => pathname === policy.path && matchesMethod(policy, method)
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldBlockDuringPasswordChange(
|
||||
pathname: string,
|
||||
method: string,
|
||||
requirePasswordChange: boolean
|
||||
): boolean {
|
||||
return (
|
||||
requirePasswordChange &&
|
||||
!isPublicApiPath(pathname, method) &&
|
||||
!isAllowedDuringPasswordChange(pathname, method)
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldBypassPublicApiAuthentication(
|
||||
pathname: string,
|
||||
method: string,
|
||||
hasToken: boolean
|
||||
): boolean {
|
||||
return isPublicApiPath(pathname, method) && !hasToken
|
||||
}
|
||||
|
||||
export function canBindOAuthIdentity(requirePasswordChange: boolean): boolean {
|
||||
return !requirePasswordChange
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createError, getQuery, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { deleteCardCodesByIds } from '~~/server/services/cardCodeDeleteService'
|
||||
|
||||
const MAX_DELETE_COUNT = 500
|
||||
const idSchema = z.preprocess(
|
||||
(value) => (typeof value === 'number' || typeof value === 'string' ? value : undefined),
|
||||
z.coerce.number().int().positive()
|
||||
)
|
||||
|
||||
const deleteCardCodesSchema = z.object({
|
||||
id: idSchema.optional(),
|
||||
ids: z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value.split(',').map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
return value
|
||||
},
|
||||
z.array(idSchema).max(MAX_DELETE_COUNT, `单次最多删除 ${MAX_DELETE_COUNT} 个点歌券`).optional()
|
||||
)
|
||||
})
|
||||
|
||||
export const handleOpenCardCodeDelete = async (event: any) => {
|
||||
const apiKey = event.context.apiKey
|
||||
if (!apiKey) {
|
||||
throw createError({ statusCode: 401, message: 'API认证失败' })
|
||||
}
|
||||
|
||||
try {
|
||||
const query = getQuery(event)
|
||||
const body = await readBody(event).catch(() => ({})) ?? {}
|
||||
const validatedData = deleteCardCodesSchema.parse({ ...query, ...body })
|
||||
const rawIds = [
|
||||
...(validatedData.id !== undefined ? [validatedData.id] : []),
|
||||
...(validatedData.ids || [])
|
||||
]
|
||||
const ids = Array.from(new Set(rawIds))
|
||||
|
||||
if (!ids.length) {
|
||||
throw createError({ statusCode: 400, message: '缺少有效点歌券ID' })
|
||||
}
|
||||
if (ids.length > MAX_DELETE_COUNT) {
|
||||
throw createError({ statusCode: 400, message: `单次最多删除 ${MAX_DELETE_COUNT} 个点歌券` })
|
||||
}
|
||||
|
||||
const deletedRows = await deleteCardCodesByIds(ids)
|
||||
return {
|
||||
success: true,
|
||||
message: '点歌券删除成功',
|
||||
data: {
|
||||
cardCodes: deletedRows,
|
||||
deleted: deletedRows.length,
|
||||
requested: ids.length
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.statusCode) throw err
|
||||
if (err.name === 'ZodError') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `请求参数验证失败:${(err.issues || err.errors).map((issue: any) => issue.message).join(', ')}`
|
||||
})
|
||||
}
|
||||
console.error('开放API删除点歌券失败', err)
|
||||
throw createError({ statusCode: 500, message: '删除点歌券失败' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { getHeader } from 'h3'
|
||||
import { db, eq, and } from '~/drizzle/db'
|
||||
import { authSessions } from '~/drizzle/schema'
|
||||
import { parseUserAgent, type LoginMethod } from './user-agent-info'
|
||||
import { getClientIP } from './ip-utils'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export interface CreateAuthSessionOptions {
|
||||
userId: number
|
||||
jti: string
|
||||
loginMethod: LoginMethod
|
||||
expiresAt?: Date
|
||||
oauthProvider?: string
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
export async function createAuthSession(
|
||||
event: any,
|
||||
options: CreateAuthSessionOptions
|
||||
): Promise<string> {
|
||||
const { userId, jti, loginMethod, oauthProvider } = options
|
||||
const ua = getHeader(event, 'user-agent')
|
||||
const { browser, operatingSystem, device } = parseUserAgent(ua)
|
||||
const ip = getClientIP(event)
|
||||
const now = new Date()
|
||||
const expiresAt = options.expiresAt ?? new Date(now.getTime() + DEFAULT_SESSION_TTL_MS)
|
||||
|
||||
try {
|
||||
let method = loginMethod
|
||||
if (oauthProvider && method === 'oauth') {
|
||||
method = `oauth:${oauthProvider}` as any
|
||||
}
|
||||
|
||||
await db.insert(authSessions).values({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
sessionToken: jti,
|
||||
browser,
|
||||
operatingSystem,
|
||||
device,
|
||||
loginMethod: method,
|
||||
ipAddress: ip,
|
||||
userAgent: ua ?? null,
|
||||
createdAt: now,
|
||||
lastActiveAt: now,
|
||||
expiresAt,
|
||||
isRevoked: false
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[AuthSession] 创建会话失败,继续无会话登录:', error)
|
||||
}
|
||||
|
||||
return jti
|
||||
}
|
||||
|
||||
export async function isSessionRevokedByJti(jti: string | undefined): Promise<boolean> {
|
||||
if (!jti) return false
|
||||
try {
|
||||
const row = await db.query.authSessions.findFirst({
|
||||
where: and(eq(authSessions.sessionToken, jti)),
|
||||
columns: { isRevoked: true, expiresAt: true }
|
||||
})
|
||||
if (!row) return false
|
||||
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return true
|
||||
return Boolean(row.isRevoked)
|
||||
} catch (error) {
|
||||
console.warn('[AuthSession] 会话状态校验失败,默认放行:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function touchAuthSession(jti: string | undefined): Promise<void> {
|
||||
if (!jti) return
|
||||
try {
|
||||
await db
|
||||
.update(authSessions)
|
||||
.set({ lastActiveAt: new Date() })
|
||||
.where(eq(authSessions.sessionToken, jti))
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { randomInt, timingSafeEqual } from 'node:crypto'
|
||||
import { getServerTimestamp } from '~~/server/utils/serverTime'
|
||||
import { getRedisClient, buildRedisKey } from '~~/server/utils/redis'
|
||||
|
||||
const EMAIL_CODE_TTL_MS = 5 * 60 * 1000
|
||||
const EMAIL_CODE_COOLDOWN_MS = 60 * 1000
|
||||
const EMAIL_CODE_MAX_ATTEMPTS = 5
|
||||
const EMAIL_CODE_DAILY_LIMIT = 5
|
||||
const EMAIL_CODE_DAILY_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
type EmailCodeEntry = {
|
||||
code: string
|
||||
expiresAt: number
|
||||
createdAt: number
|
||||
attempts: number
|
||||
}
|
||||
|
||||
const emailCodes = new Map<string, EmailCodeEntry>()
|
||||
const dailySendCounts = new Map<string, { count: number; dayKey: string }>()
|
||||
|
||||
const normalizeEmail = (email: string) => email.trim().toLowerCase()
|
||||
|
||||
const getDayKey = () => {
|
||||
const now = new Date(getServerTimestamp())
|
||||
return `${now.getUTCFullYear()}-${now.getUTCMonth() + 1}-${now.getUTCDate()}`
|
||||
}
|
||||
|
||||
const codeRedisKey = (email: string) => buildRedisKey('email-code', 'code', normalizeEmail(email))
|
||||
const attemptsRedisKey = (email: string) => buildRedisKey('email-code', 'attempts', normalizeEmail(email))
|
||||
const cooldownRedisKey = (email: string) => buildRedisKey('email-code', 'cooldown', normalizeEmail(email))
|
||||
const dailyRedisKey = (email: string) => buildRedisKey('email-code', 'daily', normalizeEmail(email), getDayKey())
|
||||
|
||||
const VERIFY_EMAIL_CODE_SCRIPT = `
|
||||
local stored = redis.call('GET', KEYS[1])
|
||||
if not stored then
|
||||
return { 0, 0 }
|
||||
end
|
||||
local attempts = redis.call('INCR', KEYS[2])
|
||||
if attempts == 1 then
|
||||
redis.call('EXPIRE', KEYS[2], 300)
|
||||
end
|
||||
if ARGV[1] == stored then
|
||||
redis.call('DEL', KEYS[1], KEYS[2])
|
||||
return { 1, attempts }
|
||||
end
|
||||
if attempts >= tonumber(ARGV[2]) then
|
||||
redis.call('DEL', KEYS[1], KEYS[2])
|
||||
return { 2, attempts }
|
||||
end
|
||||
return { 0, attempts }
|
||||
`
|
||||
|
||||
const safeCodeEqual = (a: string, b: string): boolean => {
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
|
||||
}
|
||||
|
||||
export function generateEmailCode(): string {
|
||||
return String(randomInt(100000, 1000000))
|
||||
}
|
||||
|
||||
export async function storeEmailCode(email: string, code: string): Promise<void> {
|
||||
const normalized = normalizeEmail(email)
|
||||
const now = getServerTimestamp()
|
||||
emailCodes.set(normalized, {
|
||||
code,
|
||||
expiresAt: now + EMAIL_CODE_TTL_MS,
|
||||
createdAt: now,
|
||||
attempts: 0
|
||||
})
|
||||
|
||||
const dayKey = getDayKey()
|
||||
const record = dailySendCounts.get(normalized)
|
||||
if (!record || record.dayKey !== dayKey) {
|
||||
dailySendCounts.set(normalized, { count: 1, dayKey })
|
||||
} else {
|
||||
record.count++
|
||||
}
|
||||
|
||||
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
|
||||
try {
|
||||
client = getRedisClient()
|
||||
if (!client) return
|
||||
const ttlSeconds = Math.ceil(EMAIL_CODE_TTL_MS / 1000)
|
||||
await client.set(codeRedisKey(email), code, { EX: ttlSeconds })
|
||||
await client.set(cooldownRedisKey(email), '1', {
|
||||
EX: Math.ceil(EMAIL_CODE_COOLDOWN_MS / 1000)
|
||||
})
|
||||
const dailyKey = dailyRedisKey(email)
|
||||
const dailyCount = await client.incr(dailyKey)
|
||||
if (dailyCount === 1) {
|
||||
await client.expire(dailyKey, Math.ceil(EMAIL_CODE_DAILY_WINDOW_MS / 1000))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EmailCode] Redis 写入失败,已回退内存存储:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function isEmailCodeCooldownActive(email: string): Promise<boolean> {
|
||||
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
|
||||
try {
|
||||
client = getRedisClient()
|
||||
if (client) {
|
||||
return Boolean(await client.exists(cooldownRedisKey(email)))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error)
|
||||
}
|
||||
const entry = emailCodes.get(normalizeEmail(email))
|
||||
return Boolean(entry && getServerTimestamp() - entry.createdAt < EMAIL_CODE_COOLDOWN_MS)
|
||||
}
|
||||
|
||||
export async function isEmailCodeDailyLimitReached(email: string): Promise<boolean> {
|
||||
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
|
||||
try {
|
||||
client = getRedisClient()
|
||||
if (client) {
|
||||
const count = await client.get(dailyRedisKey(email))
|
||||
return Number(count || 0) >= EMAIL_CODE_DAILY_LIMIT
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error)
|
||||
}
|
||||
const normalized = normalizeEmail(email)
|
||||
const dayKey = getDayKey()
|
||||
const record = dailySendCounts.get(normalized)
|
||||
return Boolean(record && record.dayKey === dayKey && record.count >= EMAIL_CODE_DAILY_LIMIT)
|
||||
}
|
||||
|
||||
export async function verifyEmailCode(email: string, code: string): Promise<boolean> {
|
||||
const trimmedCode = code.trim()
|
||||
let client
|
||||
try {
|
||||
client = getRedisClient()
|
||||
if (client) {
|
||||
const result = await client.eval(VERIFY_EMAIL_CODE_SCRIPT, {
|
||||
keys: [codeRedisKey(email), attemptsRedisKey(email)],
|
||||
arguments: [trimmedCode, String(EMAIL_CODE_MAX_ATTEMPTS)]
|
||||
})
|
||||
if (Array.isArray(result)) {
|
||||
return Number(result[0]) === 1
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EmailCode] Redis 校验失败,已回退内存校验:', error)
|
||||
}
|
||||
|
||||
const normalized = normalizeEmail(email)
|
||||
const entry = emailCodes.get(normalized)
|
||||
if (!entry) return false
|
||||
if (getServerTimestamp() > entry.expiresAt) {
|
||||
emailCodes.delete(normalized)
|
||||
return false
|
||||
}
|
||||
if (safeCodeEqual(entry.code, trimmedCode)) {
|
||||
emailCodes.delete(normalized)
|
||||
return true
|
||||
}
|
||||
entry.attempts++
|
||||
if (entry.attempts >= EMAIL_CODE_MAX_ATTEMPTS) {
|
||||
emailCodes.delete(normalized)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function cleanupExpiredEmailCodes(): void {
|
||||
const now = getServerTimestamp()
|
||||
for (const [email, entry] of emailCodes) {
|
||||
if (now > entry.expiresAt) emailCodes.delete(email)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const smartSort = (a: string, b: string) => {
|
||||
const gradeOrder: Record<string, number> = {
|
||||
'初一': 1, '初二': 2, '初三': 3,
|
||||
'高一': 4, '高二': 5, '高三': 6,
|
||||
'大一': 7, '大二': 8, '大三': 9, '大四': 10,
|
||||
'教师': 99, '教职工': 99
|
||||
}
|
||||
|
||||
const weightA = gradeOrder[a]
|
||||
const weightB = gradeOrder[b]
|
||||
|
||||
if (weightA !== undefined && weightB !== undefined) return weightA - weightB
|
||||
if (weightA !== undefined) return -1
|
||||
if (weightB !== undefined) return 1
|
||||
|
||||
return a.localeCompare(b, 'zh-CN', { numeric: true })
|
||||
}
|
||||
|
||||
export interface GradeClassOption {
|
||||
grade: string
|
||||
class: string
|
||||
}
|
||||
|
||||
export const resolveGradeClassOptions = (
|
||||
configItems: GradeClassOption[],
|
||||
fallbackItems: GradeClassOption[]
|
||||
): GradeClassOption[] => {
|
||||
const source = configItems.length > 0 ? configItems : fallbackItems
|
||||
|
||||
return source
|
||||
.filter((item): item is GradeClassOption => Boolean(item.grade?.trim()) && Boolean(item.class?.trim()))
|
||||
.sort((a, b) => {
|
||||
const gradeResult = smartSort(a.grade, b.grade)
|
||||
return gradeResult || smartSort(a.class, b.class)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { and, isNotNull, eq, ne, sql } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users, gradeClasses } from '~/drizzle/schema'
|
||||
import { resolveGradeClassOptions, type GradeClassOption } from './grade-class-core'
|
||||
|
||||
export async function fetchGradeClassOptions(): Promise<GradeClassOption[]> {
|
||||
const [configRows, userRows] = await Promise.all([
|
||||
db.select({
|
||||
grade: gradeClasses.grade,
|
||||
class: gradeClasses.class
|
||||
}).from(gradeClasses),
|
||||
db
|
||||
.selectDistinct({
|
||||
grade: users.grade,
|
||||
class: users.class
|
||||
})
|
||||
.from(users)
|
||||
.where(and(eq(users.status, 'active'), isNotNull(users.grade), isNotNull(users.class)))
|
||||
])
|
||||
|
||||
return resolveGradeClassOptions(configRows, userRows)
|
||||
}
|
||||
|
||||
export async function isGradeClassValid(grade: string, studentClass: string): Promise<boolean> {
|
||||
const configHit = await db.query.gradeClasses.findFirst({
|
||||
where: (t, { eq: eq_, and: and_ }) => and_(eq_(t.grade, grade), eq_(t.class, studentClass)),
|
||||
columns: { id: true }
|
||||
})
|
||||
|
||||
if (configHit) return true
|
||||
|
||||
const configCount = await db
|
||||
.select({ count: () => sql<number>`count(*)::int` })
|
||||
.from(gradeClasses)
|
||||
.where(and(ne(gradeClasses.grade, ''), ne(gradeClasses.class, '')))
|
||||
if ((configCount[0]?.count || 0) > 0) return false
|
||||
|
||||
const userHit = await db.query.users.findFirst({
|
||||
where: (t, { eq: eq_, and: and_ }) =>
|
||||
and_(eq_(t.status, 'active'), eq_(t.grade, grade), eq_(t.class, studentClass)),
|
||||
columns: { id: true }
|
||||
})
|
||||
|
||||
return Boolean(userHit)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export const NOTIFICATION_TITLE_MAX_LENGTH = 200
|
||||
export const NOTIFICATION_CONTENT_MAX_LENGTH = 20000
|
||||
export const NOTIFICATION_SOURCES = {
|
||||
SYSTEM: 'SYSTEM',
|
||||
ADMIN_MANUAL: 'ADMIN_MANUAL'
|
||||
} as const
|
||||
|
||||
export type NotificationSenderInput = {
|
||||
id: number
|
||||
name?: string | null
|
||||
username?: string | null
|
||||
}
|
||||
|
||||
export type NotificationSenderSnapshot = {
|
||||
senderId: number | null
|
||||
senderName: string | null
|
||||
senderUsername: string | null
|
||||
}
|
||||
|
||||
export const resolveNotificationSource = (sender: NotificationSenderInput | null) =>
|
||||
sender ? NOTIFICATION_SOURCES.ADMIN_MANUAL : NOTIFICATION_SOURCES.SYSTEM
|
||||
|
||||
export const createNotificationSenderSnapshot = (
|
||||
sender: NotificationSenderInput | null = null
|
||||
): NotificationSenderSnapshot => ({
|
||||
senderId: sender?.id ?? null,
|
||||
senderName: sender?.name?.trim() || null,
|
||||
senderUsername: sender?.username?.trim() || null
|
||||
})
|
||||
|
||||
export const serializeNotificationSender = (snapshot: NotificationSenderSnapshot) =>
|
||||
snapshot.senderId
|
||||
? {
|
||||
id: snapshot.senderId,
|
||||
name: snapshot.senderName,
|
||||
username: snapshot.senderUsername
|
||||
}
|
||||
: null
|
||||
|
||||
export const shouldCheckImportantNotification = (
|
||||
authenticated: boolean,
|
||||
userId?: number | null,
|
||||
requirePasswordChange = false
|
||||
): boolean =>
|
||||
authenticated && Number.isInteger(userId) && Number(userId) > 0 && !requirePasswordChange
|
||||
|
||||
export const canSendSystemNotification = (role?: string): boolean =>
|
||||
role === 'ADMIN' || role === 'SUPER_ADMIN'
|
||||
|
||||
export const resolveImportantFlag = (value: unknown): boolean | null => {
|
||||
if (value === undefined) return false
|
||||
return typeof value === 'boolean' ? value : null
|
||||
}
|
||||
|
||||
export const shouldDeliverSystemNotification = (
|
||||
important: boolean,
|
||||
notificationsEnabled?: boolean
|
||||
): boolean => important || notificationsEnabled !== false
|
||||
|
||||
export const shouldRetainNotificationHistory = (type: string, source: string): boolean =>
|
||||
type === 'SYSTEM_NOTICE' && source === NOTIFICATION_SOURCES.ADMIN_MANUAL
|
||||
|
||||
export const createNotificationReadUpdate = (updatedAt = new Date()) => ({
|
||||
read: true as const,
|
||||
updatedAt
|
||||
})
|
||||
|
||||
export const createNotificationUserDeleteUpdate = (updatedAt = new Date()) => ({
|
||||
userDeleted: true as const,
|
||||
read: true as const,
|
||||
updatedAt
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
interface InitialPasswordUserState {
|
||||
requirePasswordChange?: boolean | null
|
||||
}
|
||||
|
||||
interface StoredPasswordState {
|
||||
password?: string | null
|
||||
passwordChangedAt?: Date | string | null
|
||||
}
|
||||
|
||||
export interface PasswordSetupState {
|
||||
hasSetPassword: boolean
|
||||
needsInitialPasswordSetup: boolean
|
||||
}
|
||||
|
||||
export function getPasswordSetupState(
|
||||
storedPassword: StoredPasswordState,
|
||||
requirePasswordChange: boolean
|
||||
): PasswordSetupState {
|
||||
const hasSetPassword = !!storedPassword.password
|
||||
return {
|
||||
hasSetPassword,
|
||||
needsInitialPasswordSetup:
|
||||
!storedPassword.passwordChangedAt && (requirePasswordChange || !hasSetPassword)
|
||||
}
|
||||
}
|
||||
|
||||
export function canSetInitialPassword(
|
||||
user: InitialPasswordUserState,
|
||||
storedPassword: StoredPasswordState
|
||||
): boolean {
|
||||
return getPasswordSetupState(storedPassword, !!user.requirePasswordChange)
|
||||
.needsInitialPasswordSetup
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { asc, eq, sql } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { systemSettings } from '~/drizzle/schema'
|
||||
import { SYSTEM_SETTINGS_DEFAULTS } from './system-settings-defaults'
|
||||
import { getServerTimestamp } from './serverTime'
|
||||
|
||||
export interface InstanceIdInfo {
|
||||
instanceId: string
|
||||
persisted: boolean
|
||||
}
|
||||
|
||||
let cachedInstanceIdInfo: InstanceIdInfo | null = null
|
||||
let cachedInstanceIdExpiresAt = 0
|
||||
let pendingInstanceIdPromise: Promise<string> | null = null
|
||||
let pendingInstanceIdInfoPromise: Promise<InstanceIdInfo> | null = null
|
||||
|
||||
const TEMPORARY_INSTANCE_ID_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
const loadOrCreateInstanceId = async (): Promise<string> => {
|
||||
const settingsResult = await db
|
||||
.select({ id: systemSettings.id, instanceId: systemSettings.instanceId })
|
||||
.from(systemSettings)
|
||||
.orderBy(asc(systemSettings.id))
|
||||
.limit(1)
|
||||
|
||||
const settings = settingsResult[0]
|
||||
|
||||
if (settings?.instanceId) {
|
||||
return settings.instanceId
|
||||
}
|
||||
|
||||
const instanceId = randomUUID()
|
||||
|
||||
if (!settings) {
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({ id: 1, ...SYSTEM_SETTINGS_DEFAULTS, instanceId })
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.id,
|
||||
set: { instanceId, updatedAt: new Date() }
|
||||
})
|
||||
|
||||
await db.execute(sql`
|
||||
SELECT setval(pg_get_serial_sequence('"SystemSettings"', 'id'), (SELECT MAX(id) FROM "SystemSettings"))
|
||||
`)
|
||||
} else {
|
||||
await db
|
||||
.update(systemSettings)
|
||||
.set({ instanceId, updatedAt: new Date() })
|
||||
.where(eq(systemSettings.id, settings.id))
|
||||
}
|
||||
|
||||
return instanceId
|
||||
}
|
||||
|
||||
const getCachedInstanceIdInfo = (): InstanceIdInfo | null => {
|
||||
if (!cachedInstanceIdInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (cachedInstanceIdInfo.persisted || cachedInstanceIdExpiresAt > getServerTimestamp()) {
|
||||
return cachedInstanceIdInfo
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const getInstanceIdInfo = async (): Promise<InstanceIdInfo> => {
|
||||
const cached = getCachedInstanceIdInfo()
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
if (!pendingInstanceIdInfoPromise) {
|
||||
pendingInstanceIdInfoPromise = loadOrCreateInstanceId()
|
||||
.then((instanceId) => {
|
||||
const info = { instanceId, persisted: true }
|
||||
cachedInstanceIdInfo = info
|
||||
cachedInstanceIdExpiresAt = Number.POSITIVE_INFINITY
|
||||
return info
|
||||
})
|
||||
.catch((error) => {
|
||||
const fallbackInstanceId = cachedInstanceIdInfo?.persisted === false
|
||||
? cachedInstanceIdInfo.instanceId
|
||||
: randomUUID()
|
||||
|
||||
const info = { instanceId: fallbackInstanceId, persisted: false }
|
||||
cachedInstanceIdInfo = info
|
||||
cachedInstanceIdExpiresAt = getServerTimestamp() + TEMPORARY_INSTANCE_ID_CACHE_TTL_MS
|
||||
console.warn('[Instance ID] Failed to persist instance ID, using temporary fallback value:', error)
|
||||
return info
|
||||
})
|
||||
.finally(() => {
|
||||
pendingInstanceIdInfoPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return pendingInstanceIdInfoPromise
|
||||
}
|
||||
|
||||
export const getInstanceId = async (): Promise<string> => {
|
||||
if (!pendingInstanceIdPromise) {
|
||||
pendingInstanceIdPromise = getInstanceIdInfo()
|
||||
.then(({ instanceId }) => instanceId)
|
||||
.finally(() => {
|
||||
pendingInstanceIdPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return pendingInstanceIdPromise
|
||||
}
|
||||
@@ -38,30 +38,34 @@ export class JWTEnhanced {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成JWT token
|
||||
* 生成JWT token(返回 token 字符串,兼容旧调用)
|
||||
*/
|
||||
static generateToken(userId: number, role: string): string {
|
||||
return this.generateTokenWithJti(userId, role).token
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成JWT token 并返回 jti(会话表关联需要)
|
||||
*/
|
||||
static generateTokenWithJti(userId: number, role: string): { token: string; jti: string } {
|
||||
if (!this.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is not set')
|
||||
}
|
||||
|
||||
// 生成唯一的JWT ID
|
||||
const jti = `${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// JWT载荷
|
||||
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
|
||||
userId,
|
||||
role,
|
||||
jti
|
||||
}
|
||||
|
||||
// 生成token
|
||||
const token = jwt.sign(payload, this.JWT_SECRET, {
|
||||
expiresIn: this.TOKEN_EXPIRES
|
||||
})
|
||||
|
||||
console.log(`Generated JWT token for user ${userId}`)
|
||||
return token
|
||||
return { token, jti }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
export const NOTIFICATION_HISTORY_STATUSES = ['ALL', 'READ', 'UNREAD'] as const
|
||||
|
||||
export type NotificationHistoryStatus = (typeof NOTIFICATION_HISTORY_STATUSES)[number]
|
||||
|
||||
export const NOTIFICATION_HISTORY_TYPES = ['ALL', 'IMPORTANT', 'NORMAL'] as const
|
||||
export const NOTIFICATION_HISTORY_SORT_ORDERS = ['DESC', 'ASC'] as const
|
||||
export const NOTIFICATION_HISTORY_KEYWORD_MAX_LENGTH = 100
|
||||
|
||||
export type NotificationHistoryType = (typeof NOTIFICATION_HISTORY_TYPES)[number]
|
||||
export type NotificationHistorySortOrder = (typeof NOTIFICATION_HISTORY_SORT_ORDERS)[number]
|
||||
|
||||
export const resolveNotificationHistoryFilters = (values: {
|
||||
keyword?: unknown
|
||||
type?: unknown
|
||||
sender?: unknown
|
||||
sortOrder?: unknown
|
||||
}): {
|
||||
keyword: string
|
||||
type: NotificationHistoryType
|
||||
senderId: number | null
|
||||
sortOrder: NotificationHistorySortOrder
|
||||
} | null => {
|
||||
if (values.keyword !== undefined && typeof values.keyword !== 'string') return null
|
||||
|
||||
const keyword = typeof values.keyword === 'string' ? values.keyword.trim() : ''
|
||||
if (keyword.length > NOTIFICATION_HISTORY_KEYWORD_MAX_LENGTH) return null
|
||||
|
||||
if (values.type !== undefined && typeof values.type !== 'string') return null
|
||||
const type = typeof values.type === 'string' && values.type.trim()
|
||||
? values.type.trim().toUpperCase()
|
||||
: 'ALL'
|
||||
if (!NOTIFICATION_HISTORY_TYPES.includes(type as NotificationHistoryType)) return null
|
||||
|
||||
let senderId: number | null = null
|
||||
if (values.sender !== undefined && values.sender !== null && values.sender !== '') {
|
||||
if (typeof values.sender !== 'string') return null
|
||||
const parsedSenderId = Number(values.sender.trim())
|
||||
if (!Number.isInteger(parsedSenderId) || parsedSenderId <= 0) return null
|
||||
senderId = parsedSenderId
|
||||
}
|
||||
|
||||
if (values.sortOrder !== undefined && typeof values.sortOrder !== 'string') return null
|
||||
const sortOrder = typeof values.sortOrder === 'string' && values.sortOrder.trim()
|
||||
? values.sortOrder.trim().toUpperCase()
|
||||
: 'DESC'
|
||||
if (!NOTIFICATION_HISTORY_SORT_ORDERS.includes(sortOrder as NotificationHistorySortOrder)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
keyword,
|
||||
type: type as NotificationHistoryType,
|
||||
senderId,
|
||||
sortOrder: sortOrder as NotificationHistorySortOrder
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveNotificationHistoryStatus = (
|
||||
value: unknown
|
||||
): NotificationHistoryStatus | null => {
|
||||
if (value === undefined || value === null || value === '') return 'ALL'
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const normalized = value.trim().toUpperCase()
|
||||
return NOTIFICATION_HISTORY_STATUSES.includes(normalized as NotificationHistoryStatus)
|
||||
? (normalized as NotificationHistoryStatus)
|
||||
: null
|
||||
}
|
||||
|
||||
export const resolveNotificationHistoryPagination = (
|
||||
pageValue: unknown,
|
||||
limitValue: unknown
|
||||
) => {
|
||||
const parsedPage = Number(pageValue)
|
||||
const parsedLimit = Number(limitValue)
|
||||
const page = Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1
|
||||
const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
offset: (page - 1) * limit
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
const LEGACY_BATCH_PATTERN = /^legacy-([1-9]\d*)$/
|
||||
|
||||
export const resolveNotificationBatchReference = (value: unknown) => {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const normalized = value.trim()
|
||||
const legacyMatch = normalized.match(LEGACY_BATCH_PATTERN)
|
||||
if (legacyMatch) {
|
||||
return {
|
||||
batchId: null,
|
||||
notificationId: Number(legacyMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
if (!UUID_PATTERN.test(normalized)) return null
|
||||
|
||||
return {
|
||||
batchId: normalized.toLowerCase(),
|
||||
notificationId: null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { db, eq, userIdentities, users } from '~/drizzle/db'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
|
||||
|
||||
type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
export interface OAuthIdentityPayload {
|
||||
provider: string
|
||||
providerUserId: string
|
||||
providerUsername?: string | null
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
export interface ExistingOAuthIdentity {
|
||||
id: number
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
export async function syncOAuthIdentityAvatar(
|
||||
tx: DbTransaction,
|
||||
user: { id: number; avatarProvider?: string | null; avatarProviderUserId?: string | null },
|
||||
existing: ExistingOAuthIdentity | null | undefined,
|
||||
payload: OAuthIdentityPayload
|
||||
): Promise<{ created: boolean; avatarSourceSet: boolean }> {
|
||||
const currentIdentities = await tx.query.userIdentities.findMany({
|
||||
where: (t, { eq: eqUserId }) => eqUserId(t.userId, user.id),
|
||||
columns: {
|
||||
id: true,
|
||||
provider: true,
|
||||
providerUserId: true,
|
||||
providerUsername: true,
|
||||
avatar: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
const currentAvatarSource = resolveAvatarSource(user, currentIdentities)
|
||||
|
||||
let created = false
|
||||
if (existing) {
|
||||
if (payload.avatar && existing.avatar !== payload.avatar) {
|
||||
await tx
|
||||
.update(userIdentities)
|
||||
.set({ avatar: payload.avatar })
|
||||
.where(eq(userIdentities.id, existing.id))
|
||||
}
|
||||
} else {
|
||||
await tx.insert(userIdentities).values({
|
||||
userId: user.id,
|
||||
provider: payload.provider,
|
||||
providerUserId: payload.providerUserId,
|
||||
providerUsername: payload.providerUsername,
|
||||
avatar: payload.avatar || null,
|
||||
createdAt: getBeijingTime()
|
||||
})
|
||||
created = true
|
||||
}
|
||||
|
||||
let avatarSourceSet = false
|
||||
if (!currentAvatarSource) {
|
||||
const updatedIdentities = await tx.query.userIdentities.findMany({
|
||||
where: (t, { eq: eqUserId }) => eqUserId(t.userId, user.id),
|
||||
columns: {
|
||||
id: true,
|
||||
provider: true,
|
||||
providerUserId: true,
|
||||
providerUsername: true,
|
||||
avatar: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
const nextAvatarSource = resolveAvatarSource(user, updatedIdentities)
|
||||
if (nextAvatarSource) {
|
||||
await tx
|
||||
.update(users)
|
||||
.set({
|
||||
avatarProvider: nextAvatarSource.provider,
|
||||
avatarProviderUserId: nextAvatarSource.providerUserId
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
avatarSourceSet = true
|
||||
}
|
||||
}
|
||||
|
||||
return { created, avatarSourceSet }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
export type OAuthProvider = 'github' | 'casdoor' | 'google' | 'oauth2' | 'aggregate'
|
||||
|
||||
export const AGGREGATE_OAUTH_LOGIN_TYPES = [
|
||||
'qq',
|
||||
'wx',
|
||||
'alipay',
|
||||
'sina',
|
||||
'baidu',
|
||||
'douyin',
|
||||
'huawei',
|
||||
'xiaomi',
|
||||
'gitee',
|
||||
'gitea',
|
||||
'bilibili',
|
||||
'kuaishou'
|
||||
] as const
|
||||
|
||||
export type AggregateOAuthLoginType = (typeof AGGREGATE_OAUTH_LOGIN_TYPES)[number]
|
||||
|
||||
const isPrivateIPv4 = (hostname: string): boolean => {
|
||||
const parts = hostname.split('.').map(Number)
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [first, second] = parts
|
||||
return (
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 100 && second >= 64 && second <= 127) ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
)
|
||||
}
|
||||
|
||||
const isPrivateIPv6 = (hostname: string): boolean => {
|
||||
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
if (!normalized.includes(':')) return false
|
||||
if (normalized.startsWith('::ffff:')) {
|
||||
return isPrivateIPv4(normalized.slice('::ffff:'.length))
|
||||
}
|
||||
return (
|
||||
normalized === '::1' ||
|
||||
normalized.startsWith('fc') ||
|
||||
normalized.startsWith('fd') ||
|
||||
/^fe[89ab]/.test(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
const isInternalHostname = (hostname: string): boolean => {
|
||||
const normalized = hostname.toLowerCase()
|
||||
if (normalized.includes(':')) return isPrivateIPv6(normalized)
|
||||
if (isPrivateIPv4(normalized)) return true
|
||||
return (
|
||||
normalized === 'localhost' ||
|
||||
!normalized.includes('.') ||
|
||||
normalized.endsWith('.localhost') ||
|
||||
normalized.endsWith('.local') ||
|
||||
normalized.endsWith('.lan') ||
|
||||
normalized.endsWith('.internal') ||
|
||||
normalized.endsWith('.home.arpa')
|
||||
)
|
||||
}
|
||||
|
||||
export const isSafeAggregateOAuthUrl = (value: string): boolean => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.username || url.password) return false
|
||||
if (url.protocol === 'https:') return true
|
||||
if (url.protocol !== 'http:') return false
|
||||
return isInternalHostname(url.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeAggregateOAuthLoginTypes = (value: unknown): AggregateOAuthLoginType[] => {
|
||||
let values: unknown[] = []
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
values = value
|
||||
} else if (typeof value === 'string' && value.trim()) {
|
||||
const normalized = value.trim()
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
values = Array.isArray(parsed) ? parsed : [parsed]
|
||||
} catch {
|
||||
values = normalized.split(',')
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
values
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item): item is AggregateOAuthLoginType =>
|
||||
AGGREGATE_OAUTH_LOGIN_TYPES.includes(item as AggregateOAuthLoginType)
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
export const getAggregateOAuthLoginTypesOrDefault = (value: unknown): AggregateOAuthLoginType[] => {
|
||||
const normalized = normalizeAggregateOAuthLoginTypes(value)
|
||||
const hasConfiguredValue =
|
||||
(Array.isArray(value) && value.length > 0) ||
|
||||
(typeof value === 'string' && value.trim().length > 0)
|
||||
return normalized.length > 0 || hasConfiguredValue ? normalized : ['qq']
|
||||
}
|
||||
|
||||
export const SUPPORTED_OAUTH_PROVIDERS: OAuthProvider[] = [
|
||||
'github',
|
||||
'casdoor',
|
||||
'google',
|
||||
'oauth2',
|
||||
'aggregate'
|
||||
]
|
||||
|
||||
export const isSupportedOAuthProvider = (provider: string): provider is OAuthProvider => {
|
||||
return SUPPORTED_OAUTH_PROVIDERS.includes(provider as OAuthProvider)
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createError } from 'h3'
|
||||
import { createHmac, randomUUID } from 'node:crypto'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { systemSettings } from '~/drizzle/schema'
|
||||
import { asc } from 'drizzle-orm'
|
||||
import {
|
||||
isSafeAggregateOAuthUrl,
|
||||
getAggregateOAuthLoginTypesOrDefault
|
||||
} from './oauth-providers'
|
||||
|
||||
export interface OAuthUserInfo {
|
||||
id: string
|
||||
@@ -250,6 +258,139 @@ const strategies: Record<string, OAuthStrategy> = {
|
||||
google: googleStrategy
|
||||
}
|
||||
|
||||
async function loadAggregateSettings() {
|
||||
const row = await db
|
||||
.select({
|
||||
aggregateOAuthEnabled: systemSettings.aggregateOAuthEnabled,
|
||||
aggregateOAuthAppId: systemSettings.aggregateOAuthAppId,
|
||||
aggregateOAuthAppKey: systemSettings.aggregateOAuthAppKey,
|
||||
aggregateOAuthLoginType: systemSettings.aggregateOAuthLoginType,
|
||||
aggregateOAuthEndpoint: systemSettings.aggregateOAuthEndpoint
|
||||
})
|
||||
.from(systemSettings)
|
||||
.orderBy(asc(systemSettings.id))
|
||||
.limit(1)
|
||||
return row[0] ?? null
|
||||
}
|
||||
|
||||
function normalizeEndpoint(endpoint: string): string {
|
||||
return endpoint.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function aggregateSign(params: Record<string, string>, appKey: string): string {
|
||||
const sorted = Object.keys(params)
|
||||
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '')
|
||||
.sort()
|
||||
.map((k) => `${k}=${params[k]}`)
|
||||
.join('&')
|
||||
return createHmac('sha256', appKey).update(sorted).digest('hex')
|
||||
}
|
||||
|
||||
const aggregateStrategy: OAuthStrategy = {
|
||||
async getAuthorizeUrl(redirectUri: string, state: string) {
|
||||
const settings = await loadAggregateSettings()
|
||||
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
||||
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
||||
}
|
||||
if (!isSafeAggregateOAuthUrl(settings.aggregateOAuthEndpoint)) {
|
||||
throw createError({ statusCode: 500, message: '聚合登录端点不安全或格式错误' })
|
||||
}
|
||||
|
||||
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
|
||||
const loginType = loginTypes[0] || 'qq'
|
||||
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = randomUUID().replace(/-/g, '')
|
||||
const baseParams: Record<string, string> = {
|
||||
app_id: settings.aggregateOAuthAppId,
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
login_type: loginType,
|
||||
timestamp,
|
||||
nonce
|
||||
}
|
||||
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
||||
const query = new URLSearchParams({ ...baseParams, sign }).toString()
|
||||
return `${endpoint}/oauth/authorize?${query}`
|
||||
},
|
||||
|
||||
async exchangeToken(code: string, redirectUri: string) {
|
||||
const settings = await loadAggregateSettings()
|
||||
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
||||
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
||||
}
|
||||
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = randomUUID().replace(/-/g, '')
|
||||
const baseParams: Record<string, string> = {
|
||||
app_id: settings.aggregateOAuthAppId,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
timestamp,
|
||||
nonce
|
||||
}
|
||||
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
||||
|
||||
try {
|
||||
const tokenResponse = await $fetch<any>(`${endpoint}/oauth/token`, {
|
||||
method: 'POST',
|
||||
body: { ...baseParams, sign },
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
if (tokenResponse.error) {
|
||||
throw new Error(tokenResponse.error_description || tokenResponse.error || '令牌请求失败')
|
||||
}
|
||||
const accessToken = tokenResponse.access_token || tokenResponse.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('未能获取访问令牌')
|
||||
}
|
||||
return accessToken
|
||||
} catch (e: any) {
|
||||
console.error('Aggregate OAuth token exchange failed', e)
|
||||
throw new Error(e.message || '令牌请求失败')
|
||||
}
|
||||
},
|
||||
|
||||
async getUserInfo(accessToken: string) {
|
||||
const settings = await loadAggregateSettings()
|
||||
if (!settings?.aggregateOAuthAppId || !settings?.aggregateOAuthAppKey || !settings?.aggregateOAuthEndpoint) {
|
||||
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
||||
}
|
||||
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = randomUUID().replace(/-/g, '')
|
||||
const baseParams: Record<string, string> = {
|
||||
app_id: settings.aggregateOAuthAppId,
|
||||
access_token: accessToken,
|
||||
timestamp,
|
||||
nonce
|
||||
}
|
||||
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
||||
|
||||
try {
|
||||
const userInfo = await $fetch<any>(`${endpoint}/oauth/userinfo`, {
|
||||
method: 'POST',
|
||||
body: { ...baseParams, sign },
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
return {
|
||||
id: String(userInfo.id || userInfo.openid || userInfo.sub || userInfo.userId || ''),
|
||||
username: userInfo.username || userInfo.nickname || userInfo.name || String(userInfo.id || 'user'),
|
||||
name: userInfo.name || userInfo.nickname || undefined,
|
||||
email: userInfo.email || undefined,
|
||||
avatar: userInfo.avatar || userInfo.avatar_url || userInfo.headimgurl || undefined
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Aggregate OAuth user info failed', e)
|
||||
throw new Error('获取用户信息失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strategies.aggregate = aggregateStrategy
|
||||
|
||||
export const getOAuthStrategy = (provider: string): OAuthStrategy => {
|
||||
const strategy = strategies[provider]
|
||||
if (!strategy) {
|
||||
|
||||
@@ -261,6 +261,12 @@ export const disconnectRedis = () => redisPool.disconnect()
|
||||
export const getRedisClient = () => redisPool.getClient()
|
||||
export const isRedisReady = () => redisPool.isReady()
|
||||
export const getRedisStats = () => redisPool.getStats()
|
||||
|
||||
export const REDIS_KEY_PREFIX = 'voicehub'
|
||||
|
||||
export function buildRedisKey(...segments: string[]): string {
|
||||
return [REDIS_KEY_PREFIX, ...segments].filter(Boolean).join(':')
|
||||
}
|
||||
export const executeRedisCommand = <T>(command: () => Promise<T>, fallback?: () => Promise<T>) =>
|
||||
redisPool.executeCommand(command, fallback)
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createApiError } from './apiError'
|
||||
import { SERVER_ERROR_CODES } from '../config/constants'
|
||||
|
||||
const SONG_ADMIN_ROLES = ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN']
|
||||
|
||||
export function requireSongAdmin(event: any) {
|
||||
const user = event.context?.user
|
||||
if (!user) {
|
||||
throw createApiError(401, SERVER_ERROR_CODES.AUTH_UNAUTHORIZED, '请先登录')
|
||||
}
|
||||
if (!SONG_ADMIN_ROLES.includes(user.role)) {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.AUTH_FORBIDDEN, '没有权限执行此操作')
|
||||
}
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface ScheduleVisibilityRange {
|
||||
startAt: Date | null
|
||||
endAt: Date | null
|
||||
}
|
||||
|
||||
export interface VisibilitySettingsLike {
|
||||
scheduleDaysBeforeEnabled?: boolean | null
|
||||
scheduleDaysBefore?: number | null
|
||||
scheduleDaysAfterEnabled?: boolean | null
|
||||
scheduleDaysAfter?: number | null
|
||||
}
|
||||
|
||||
export function resolveScheduleVisibilityRange(
|
||||
settings: VisibilitySettingsLike | null | undefined,
|
||||
referenceDate = new Date()
|
||||
): ScheduleVisibilityRange {
|
||||
const today = new Date(
|
||||
referenceDate.getFullYear(),
|
||||
referenceDate.getMonth(),
|
||||
referenceDate.getDate()
|
||||
)
|
||||
|
||||
let startAt: Date | null = null
|
||||
let endAt: Date | null = null
|
||||
|
||||
if (settings?.scheduleDaysBeforeEnabled) {
|
||||
const days = Math.max(0, Number(settings.scheduleDaysBefore) || 0)
|
||||
const d = new Date(today)
|
||||
d.setDate(d.getDate() - days)
|
||||
startAt = d
|
||||
}
|
||||
|
||||
if (settings?.scheduleDaysAfterEnabled) {
|
||||
const days = Math.max(0, Number(settings.scheduleDaysAfter) || 0)
|
||||
const d = new Date(today)
|
||||
d.setDate(d.getDate() + days + 1)
|
||||
endAt = d
|
||||
}
|
||||
|
||||
return { startAt, endAt }
|
||||
}
|
||||
|
||||
export function isPlayDateInRange(
|
||||
playDate: Date | string | null | undefined,
|
||||
range: ScheduleVisibilityRange
|
||||
): boolean {
|
||||
if (!playDate) return true
|
||||
const d = new Date(playDate)
|
||||
if (range.startAt && d.getTime() < range.startAt.getTime()) return false
|
||||
if (range.endAt && d.getTime() >= range.endAt.getTime()) return false
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getServerTimestamp(): number {
|
||||
return Date.now()
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { SongBlacklist } from '~/drizzle/schema'
|
||||
|
||||
export type BlacklistType = 'KEYWORD' | 'LANGUAGE' | 'GENRE' | 'SONG'
|
||||
|
||||
export interface ModerationContext {
|
||||
title?: string | null
|
||||
artist?: string | null
|
||||
genre?: string | null
|
||||
}
|
||||
|
||||
export interface ModerationResult {
|
||||
notePublic: boolean
|
||||
publicStatus: 'approved' | 'rejected' | null
|
||||
rejectedReasons: string[]
|
||||
matchedRules: { type: BlacklistType; value: string }[]
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function isCJK(text: string): boolean {
|
||||
return /[\u4e00-\u9fa5]/.test(text)
|
||||
}
|
||||
|
||||
function isLatin(text: string): boolean {
|
||||
return /[A-Za-z]/.test(text)
|
||||
}
|
||||
|
||||
function isKana(text: string): boolean {
|
||||
return /[\u3040-\u30ff]/.test(text)
|
||||
}
|
||||
|
||||
function isHangul(text: string): boolean {
|
||||
return /[\uac00-\ud7af]/.test(text)
|
||||
}
|
||||
|
||||
function isCyrillic(text: string): boolean {
|
||||
return /[\u0400-\u04ff]/.test(text)
|
||||
}
|
||||
|
||||
function isArabic(text: string): boolean {
|
||||
return /[\u0600-\u06ff]/.test(text)
|
||||
}
|
||||
|
||||
function detectLanguages(text: string): string[] {
|
||||
const result: string[] = []
|
||||
if (!text) return result
|
||||
const normalized = text.toLowerCase()
|
||||
if (isCJK(text)) result.push('zh', '中文', 'chinese', 'cjk')
|
||||
if (isLatin(text)) result.push('en', '英文', 'english', 'latin')
|
||||
if (isKana(text)) result.push('ja', '日文', 'japanese')
|
||||
if (isHangul(text)) result.push('ko', '韩文', 'korean')
|
||||
if (isCyrillic(text)) result.push('ru', '俄文', 'russian', 'cyrillic')
|
||||
if (isArabic(text)) result.push('ar', '阿拉伯文', 'arabic')
|
||||
return result
|
||||
}
|
||||
|
||||
export function moderateSubmissionNote(
|
||||
note: string | null,
|
||||
wantPublic: boolean,
|
||||
requiresApproval: boolean,
|
||||
song: ModerationContext,
|
||||
blacklistRows: SongBlacklist[]
|
||||
): ModerationResult {
|
||||
if (!note || !wantPublic) {
|
||||
return {
|
||||
notePublic: wantPublic && !!note,
|
||||
publicStatus: null,
|
||||
rejectedReasons: [],
|
||||
matchedRules: []
|
||||
}
|
||||
}
|
||||
|
||||
const noteLower = note.toLowerCase()
|
||||
const noteForKeyword = note.toLowerCase()
|
||||
const title = (song.title || '').toLowerCase()
|
||||
const artist = (song.artist || '').toLowerCase()
|
||||
const genre = (song.genre || '').toLowerCase()
|
||||
|
||||
const rejectedReasons: string[] = []
|
||||
const matchedRules: { type: BlacklistType; value: string }[] = []
|
||||
|
||||
const noteLanguages = detectLanguages(note)
|
||||
const textLanguages = new Set([
|
||||
...noteLanguages,
|
||||
...detectLanguages(song.title || ''),
|
||||
...detectLanguages(song.artist || ''),
|
||||
...detectLanguages(song.genre || '')
|
||||
])
|
||||
|
||||
for (const row of blacklistRows) {
|
||||
const t = row.type as BlacklistType
|
||||
const rawValue = row.value
|
||||
if (!rawValue) continue
|
||||
const value = rawValue.trim()
|
||||
if (!value) continue
|
||||
const valueLower = value.toLowerCase()
|
||||
|
||||
if (t === 'KEYWORD') {
|
||||
// 命中 note / title / artist / genre 的其中一个都算
|
||||
const haystacks = [noteForKeyword, title, artist, genre].filter(Boolean) as string[]
|
||||
const exact = haystacks.some((h) => h.includes(valueLower))
|
||||
const asRegex = (() => {
|
||||
try {
|
||||
const re = new RegExp(escapeRegex(value), 'i')
|
||||
return haystacks.some((h) => re.test(h))
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
if (exact || asRegex) {
|
||||
matchedRules.push({ type: t, value })
|
||||
rejectedReasons.push(`命中敏感词:${value}`)
|
||||
}
|
||||
} else if (t === 'LANGUAGE') {
|
||||
const v = valueLower
|
||||
const checkList = [
|
||||
v,
|
||||
...v.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||
]
|
||||
const hit = checkList.some((lang) => textLanguages.has(lang))
|
||||
if (hit) {
|
||||
matchedRules.push({ type: t, value })
|
||||
rejectedReasons.push(`命中语言限制:${value}`)
|
||||
}
|
||||
} else if (t === 'GENRE') {
|
||||
// 匹配流派:genre 字段或 note/title/artist 中的流派关键词
|
||||
const combinedForGenre = `${genre} ${noteForKeyword} ${title} ${artist}`.trim()
|
||||
if (!combinedForGenre) continue
|
||||
const tokens = valueLower.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||
const hit = tokens.some((tok) => combinedForGenre.includes(tok))
|
||||
if (hit) {
|
||||
matchedRules.push({ type: t, value })
|
||||
rejectedReasons.push(`命中流派限制:${value}`)
|
||||
}
|
||||
} else {
|
||||
// SONG 不参与 note 审查
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const rejected = matchedRules.length > 0
|
||||
return {
|
||||
notePublic: rejected ? false : (requiresApproval ? false : wantPublic),
|
||||
publicStatus: requiresApproval || matchedRules.length > 0 ? (rejected ? 'rejected' : 'approved') : null,
|
||||
rejectedReasons,
|
||||
matchedRules
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export const SYSTEM_SETTINGS_DEFAULTS = {
|
||||
siteTitle: '校园广播站点歌系统',
|
||||
siteVersion: '1.7.1.0',
|
||||
enableSubmissionLimit: false,
|
||||
showBlacklistKeywords: false,
|
||||
hideStudentInfo: true,
|
||||
smtpEnabled: false,
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpFromName: '校园广播站',
|
||||
enableRequestTimeLimitation: false,
|
||||
forceBlockAllRequests: false,
|
||||
enableReplayRequests: false,
|
||||
enableCollaborativeSubmission: true,
|
||||
enableSubmissionRemarks: false,
|
||||
submissionNoteRequiresApproval: false,
|
||||
telemetryEnabled: true,
|
||||
showBeianIcon: true,
|
||||
scheduleDaysDisplay: 7,
|
||||
scheduleDaysEditable: 3,
|
||||
scheduleDaysRequestable: 14,
|
||||
scheduleDaysBeforeEnabled: false,
|
||||
scheduleDaysBefore: 1,
|
||||
scheduleDaysAfterEnabled: false,
|
||||
scheduleDaysAfter: 1,
|
||||
forcePasswordChangeOnFirstLogin: false,
|
||||
enableSubmissionRestriction: false,
|
||||
submissionRestrictionScope: 'all',
|
||||
cardCodeEnabled: false,
|
||||
cardCodeSubmissionRequired: false,
|
||||
cardCodeVoteEnabled: false,
|
||||
cardCodeReplayEnabled: false,
|
||||
enableCardCodeRequests: false,
|
||||
requireCardCodeForRequests: false,
|
||||
enableCardCodeLimitBypass: false,
|
||||
submissionRestrictionEnabled: false,
|
||||
submissionRestrictionGradeEnabled: false,
|
||||
submissionRestrictionClassEnabled: false,
|
||||
submissionRestrictionGraduateEnabled: false,
|
||||
submissionRestrictionWithdrawnEnabled: false,
|
||||
captchaProvider: 'TURNSTILE',
|
||||
captchaEnabled: false,
|
||||
allowRegister: true,
|
||||
registerRequireEmail: false,
|
||||
registerRequireEmailVerify: false,
|
||||
registerDefaultRole: 'USER',
|
||||
registerRequiresApproval: false,
|
||||
oauthRegisterRequiresApproval: false,
|
||||
registerEmailRequired: false,
|
||||
registerRequiresGradeClass: false,
|
||||
oauthEnabled: false,
|
||||
oauthAllowRegister: true,
|
||||
oauthAllowLogin: true,
|
||||
oauthAllowBind: true,
|
||||
allowOAuthRegistration: false,
|
||||
oauthProviders: '[]',
|
||||
githubOAuthEnabled: false,
|
||||
casdoorOAuthEnabled: false,
|
||||
googleOAuthEnabled: false,
|
||||
aggregateOAuthEnabled: false,
|
||||
aggregateOAuthLoginType: 'qq',
|
||||
customOAuthEnabled: false,
|
||||
captchaMaxFailures: 3,
|
||||
autoBackupEnabled: false,
|
||||
autoBackupIntervalHours: 24,
|
||||
autoBackupRetentionDays: 30,
|
||||
statisticsCodeEnabled: false,
|
||||
defaultTheme: 'System',
|
||||
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
|
||||
} as const
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface UserAgentInfo {
|
||||
browser: string | null
|
||||
operatingSystem: string | null
|
||||
device: string | null
|
||||
}
|
||||
|
||||
const BROWSER_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/Edg\//i, 'Microsoft Edge'],
|
||||
[/OPR\//i, 'Opera'],
|
||||
[/Chrome\//i, 'Chrome'],
|
||||
[/Safari\//i, 'Safari'],
|
||||
[/Firefox\//i, 'Firefox'],
|
||||
[/MSIE |Trident\//i, 'Internet Explorer']
|
||||
]
|
||||
|
||||
const OS_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/Windows NT 10\.0/i, 'Windows 10/11'],
|
||||
[/Windows NT 6\.3/i, 'Windows 8.1'],
|
||||
[/Windows NT 6\.2/i, 'Windows 8'],
|
||||
[/Windows NT 6\.1/i, 'Windows 7'],
|
||||
[/Windows/i, 'Windows'],
|
||||
[/Mac OS X ([\d_.]+)/i, 'macOS'],
|
||||
[/iPhone OS ([\d_]+)/i, 'iOS'],
|
||||
[/iPad.*CPU OS ([\d_]+)/i, 'iPadOS'],
|
||||
[/Android ([\d.]+)/i, 'Android'],
|
||||
[/Linux/i, 'Linux']
|
||||
]
|
||||
|
||||
const DEVICE_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/iPhone/i, 'iPhone'],
|
||||
[/iPad/i, 'iPad'],
|
||||
[/Android.*Mobile/i, 'Android Phone'],
|
||||
[/Android/i, 'Android Tablet'],
|
||||
[/Windows Phone/i, 'Windows Phone'],
|
||||
[/Macintosh/i, 'Mac'],
|
||||
[/Windows/i, 'Windows PC'],
|
||||
[/Linux/i, 'Linux PC']
|
||||
]
|
||||
|
||||
export function parseUserAgent(userAgent: string | null | undefined): UserAgentInfo {
|
||||
const ua = (userAgent ?? '').trim()
|
||||
if (!ua) return { browser: null, operatingSystem: null, device: null }
|
||||
|
||||
let browser: string | null = null
|
||||
for (const [regex, name] of BROWSER_PATTERNS) {
|
||||
if (regex.test(ua)) {
|
||||
browser = name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let operatingSystem: string | null = null
|
||||
for (const [regex, name] of OS_PATTERNS) {
|
||||
if (regex.test(ua)) {
|
||||
operatingSystem = name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let device: string | null = null
|
||||
for (const [regex, name] of DEVICE_PATTERNS) {
|
||||
if (regex.test(ua)) {
|
||||
device = name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { browser, operatingSystem, device }
|
||||
}
|
||||
|
||||
export type LoginMethod = 'password' | 'oauth' | 'webauthn' | 'totp_2fa' | 'email_2fa'
|
||||
@@ -0,0 +1,45 @@
|
||||
interface AvatarUserState {
|
||||
avatarProvider?: string | null
|
||||
avatarProviderUserId?: string | null
|
||||
}
|
||||
|
||||
interface IdentityRow {
|
||||
provider: string
|
||||
providerUserId: string
|
||||
avatar?: string | null
|
||||
createdAt: Date | string
|
||||
}
|
||||
|
||||
export interface AvatarSource {
|
||||
provider: string | null
|
||||
providerUserId: string | null
|
||||
}
|
||||
|
||||
export function resolveAvatarSource(
|
||||
user: AvatarUserState,
|
||||
identities: IdentityRow[]
|
||||
): AvatarSource | null {
|
||||
if (user.avatarProvider) {
|
||||
return {
|
||||
provider: user.avatarProvider,
|
||||
providerUserId: user.avatarProviderUserId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...identities].sort((a, b) => {
|
||||
const at = new Date(a.createdAt).getTime()
|
||||
const bt = new Date(b.createdAt).getTime()
|
||||
return at - bt
|
||||
})
|
||||
|
||||
for (const identity of sorted) {
|
||||
if (identity.avatar) {
|
||||
return {
|
||||
provider: identity.provider,
|
||||
providerUserId: identity.providerUserId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user