Initial commit

This commit is contained in:
2026-03-08 01:34:54 +08:00
commit 1f104f73c8
441 changed files with 64911 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { asyncHandler } from '../asyncHandler'
import type { Request, Response, NextFunction } from 'express'
describe('asyncHandler', () => {
const mockReq = {} as Request
const mockRes = {} as Response
const mockNext = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('成功调用', () => {
it('应正常执行函数并返回结果', async () => {
const mockHandler = vi.fn().mockResolvedValue('操作成功')
const wrappedHandler = asyncHandler(mockHandler)
await wrappedHandler(mockReq, mockRes, mockNext)
expect(mockHandler).toHaveBeenCalledWith(mockReq, mockRes, mockNext)
expect(mockNext).not.toHaveBeenCalled()
})
it('应处理返回同步值的函数', async () => {
const mockHandler = vi.fn().mockResolvedValue({ id: 1, name: 'test' })
const wrappedHandler = asyncHandler(mockHandler)
await wrappedHandler(mockReq, mockRes, mockNext)
expect(mockHandler).toHaveBeenCalled()
expect(mockNext).not.toHaveBeenCalled()
})
})
describe('异常传播', () => {
it('应正确传播异步错误', async () => {
const asyncError = new Error('异步错误')
const mockHandler = vi.fn().mockRejectedValue(asyncError)
const wrappedHandler = asyncHandler(mockHandler)
await wrappedHandler(mockReq, mockRes, mockNext)
expect(mockHandler).toHaveBeenCalled()
expect(mockNext).toHaveBeenCalledWith(asyncError)
})
it('应处理 Promise.reject 的错误', async () => {
const error = new Error('Promise rejected')
const mockHandler = vi.fn().mockReturnValue(Promise.reject(error))
const wrappedHandler = asyncHandler(mockHandler)
await wrappedHandler(mockReq, mockRes, mockNext)
expect(mockNext).toHaveBeenCalledWith(error)
})
it('应处理非 Error 对象的异步错误', async () => {
const error = '字符串错误'
const mockHandler = vi.fn().mockRejectedValue(error)
const wrappedHandler = asyncHandler(mockHandler)
await wrappedHandler(mockReq, mockRes, mockNext)
expect(mockNext).toHaveBeenCalledWith(error)
})
})
describe('函数调用时机', () => {
it('应立即调用底层函数', () => {
const mockHandler = vi.fn().mockResolvedValue('result')
const wrappedHandler = asyncHandler(mockHandler)
wrappedHandler(mockReq, mockRes, mockNext)
expect(mockHandler).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi } from 'vitest'
import { successResponse, errorResponse } from '../response'
import type { Response } from 'express'
vi.mock('express', () => ({
default: {},
}))
describe('successResponse', () => {
it('应返回正确格式的成功响应(默认状态码)', () => {
const mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
} as unknown as Response
const data = { message: '操作成功' }
successResponse(mockRes, data)
expect(mockRes.status).toHaveBeenCalledWith(200)
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: { message: '操作成功' },
})
})
it('应使用自定义状态码', () => {
const mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
} as unknown as Response
const data = { id: 123 }
successResponse(mockRes, data, 201)
expect(mockRes.status).toHaveBeenCalledWith(201)
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: { id: 123 },
})
})
it('应正确处理数组数据', () => {
const mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
} as unknown as Response
const data = [1, 2, 3]
successResponse(mockRes, data)
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: [1, 2, 3],
})
})
})
describe('errorResponse', () => {
it('应返回正确格式的错误响应', () => {
const mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
} as unknown as Response
errorResponse(mockRes, 400, 'BAD_REQUEST', '请求参数错误')
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: {
code: 'BAD_REQUEST',
message: '请求参数错误',
},
})
})
it('应在非生产环境包含 details', () => {
const mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
} as unknown as Response
const details = { field: 'username', reason: 'required' }
errorResponse(mockRes, 422, 'VALIDATION_ERROR', '验证失败', details)
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '验证失败',
details: { field: 'username', reason: 'required' },
},
})
})
it('应在生产环境不包含 details', () => {
const originalEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
const mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
} as unknown as Response
const details = { field: 'username', reason: 'required' }
errorResponse(mockRes, 422, 'VALIDATION_ERROR', '验证失败', details)
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '验证失败',
details: undefined,
},
})
process.env.NODE_ENV = originalEnv
})
it('应正确处理不带 details 的错误响应', () => {
const mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
} as unknown as Response
errorResponse(mockRes, 404, 'NOT_FOUND', '资源不存在')
expect(mockRes.status).toHaveBeenCalledWith(404)
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: {
code: 'NOT_FOUND',
message: '资源不存在',
},
})
})
})

10
api/utils/asyncHandler.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextFunction, Request, Response } from 'express'
export const asyncHandler =
<TReq extends Request = Request, TRes extends Response = Response>(
fn: (req: TReq, res: TRes, next: NextFunction) => unknown | Promise<unknown>,
) =>
(req: TReq, res: TRes, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next)
}

126
api/utils/file.ts Normal file
View File

@@ -0,0 +1,126 @@
import fs from 'fs/promises'
import path from 'path'
import { InternalError, ValidationError } from '../../shared/errors/index.js'
export const getUniqueFilename = async (imagesDirFullPath: string, baseName: string, ext: string) => {
const maxAttempts = 1000
for (let i = 0; i < maxAttempts; i++) {
const suffix = i === 0 ? '' : `-${i + 1}`
const filename = `${baseName}${suffix}${ext}`
const fullPath = path.join(imagesDirFullPath, filename)
try {
await fs.access(fullPath)
} catch {
return filename
}
}
throw new InternalError('Failed to generate unique filename')
}
export const mimeToExt: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
}
const IMAGE_MAGIC_BYTES: Record<string, { bytes: number[]; offset?: number }> = {
'image/png': { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
'image/jpeg': { bytes: [0xff, 0xd8, 0xff] },
'image/gif': { bytes: [0x47, 0x49, 0x46, 0x38] },
'image/webp': { bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 },
}
const WEBP_RIFF_HEADER = [0x52, 0x49, 0x46, 0x46]
const WEBP_WEBP_MARKER = [0x57, 0x45, 0x42, 0x50]
const MIN_IMAGE_SIZE = 16
const MAX_IMAGE_SIZE = 8 * 1024 * 1024
export const validateImageBuffer = (buffer: Buffer, claimedMimeType: string): void => {
if (buffer.byteLength < MIN_IMAGE_SIZE) {
throw new ValidationError('Image file is too small or corrupted')
}
if (buffer.byteLength > MAX_IMAGE_SIZE) {
throw new ValidationError('Image file is too large')
}
const magicInfo = IMAGE_MAGIC_BYTES[claimedMimeType]
if (!magicInfo) {
throw new ValidationError('Unsupported image type for content validation')
}
const offset = magicInfo.offset || 0
const expectedBytes = magicInfo.bytes
for (let i = 0; i < expectedBytes.length; i++) {
if (buffer[offset + i] !== expectedBytes[i]) {
throw new ValidationError('Image content does not match the claimed file type')
}
}
if (claimedMimeType === 'image/webp') {
if (buffer.byteLength < 12) {
throw new ValidationError('WebP image is corrupted')
}
for (let i = 0; i < WEBP_WEBP_MARKER.length; i++) {
if (buffer[8 + i] !== WEBP_WEBP_MARKER[i]) {
throw new ValidationError('WebP image content is invalid')
}
}
}
}
export const detectImageMimeType = (buffer: Buffer): string | null => {
if (buffer.byteLength < 8) return null
if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
) {
return 'image/png'
}
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return 'image/jpeg'
}
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
return 'image/gif'
}
if (
buffer[0] === 0x52 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x46 &&
buffer[8] === 0x57 &&
buffer[9] === 0x45 &&
buffer[10] === 0x42 &&
buffer[11] === 0x50
) {
return 'image/webp'
}
return null
}
export const sanitizeFilename = (filename: string): string => {
let sanitized = filename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
sanitized = sanitized.replace(/^\.+|\.+$/g, '')
sanitized = sanitized.replace(/\.{2,}/g, '.')
if (sanitized.length > 200) {
const ext = path.extname(filename)
const baseName = path.basename(filename, ext)
sanitized = baseName.substring(0, 200 - ext.length) + ext
}
return sanitized || 'unnamed'
}

15
api/utils/logger.ts Normal file
View File

@@ -0,0 +1,15 @@
type LogFn = (...args: unknown[]) => void
const createLogger = () => {
const isProd = process.env.NODE_ENV === 'production'
const debug: LogFn = isProd ? () => {} : console.debug.bind(console)
const info: LogFn = console.info.bind(console)
const warn: LogFn = console.warn.bind(console)
const error: LogFn = console.error.bind(console)
return { debug, info, warn, error }
}
export const logger = createLogger()

106
api/utils/pathSafety.ts Normal file
View File

@@ -0,0 +1,106 @@
import path from 'path'
import fs from 'fs/promises'
import { NOTEBOOK_ROOT } from '../config/paths.js'
import { AccessDeniedError } from '../../shared/errors/index.js'
const DANGEROUS_PATTERNS = [
/\.\./,
/\0/,
/%2e%2e[%/]/i,
/%252e%252e[%/]/i,
/\.\.%2f/i,
/\.\.%5c/i,
/%c0%ae/i,
/%c1%9c/i,
/%c0%ae%c0%ae/i,
/%c1%9c%c1%9c/i,
/\.\.%c0%af/i,
/\.\.%c1%9c/i,
/%252e/i,
/%uff0e/i,
/%u002e/i,
]
const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]|CLOCK\$)$/i
const DOUBLE_ENCODE_PATTERNS = [
/%25[0-9a-fA-F]{2}/,
/%[0-9a-fA-F]{2}%[0-9a-fA-F]{2}/,
]
export const normalizeRelPath = (input: string) => {
const trimmed = input.replace(/\0/g, '').trim()
return trimmed.replace(/^[/\\]+/, '')
}
export const containsPathTraversal = (input: string): boolean => {
const decoded = decodeURIComponentSafe(input)
return DANGEROUS_PATTERNS.some(pattern => pattern.test(input) || pattern.test(decoded))
}
export const containsDoubleEncoding = (input: string): boolean => {
return DOUBLE_ENCODE_PATTERNS.some(pattern => pattern.test(input))
}
export const hasPathSecurityIssues = (input: string): boolean => {
return containsPathTraversal(input) || containsDoubleEncoding(input)
}
const decodeURIComponentSafe = (input: string): string => {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
export const resolveNotebookPath = (relPath: string) => {
if (hasPathSecurityIssues(relPath)) {
throw new AccessDeniedError('Path traversal detected')
}
const safeRelPath = normalizeRelPath(relPath)
const notebookRoot = path.resolve(NOTEBOOK_ROOT)
const fullPath = path.resolve(notebookRoot, safeRelPath)
if (!fullPath.startsWith(notebookRoot)) {
throw new AccessDeniedError('Access denied')
}
return { safeRelPath, fullPath }
}
export const resolveNotebookPathSafe = async (relPath: string) => {
if (hasPathSecurityIssues(relPath)) {
throw new AccessDeniedError('Path traversal detected')
}
const safeRelPath = normalizeRelPath(relPath)
const notebookRoot = path.resolve(NOTEBOOK_ROOT)
const fullPath = path.resolve(notebookRoot, safeRelPath)
try {
await fs.access(fullPath)
} catch {
return { safeRelPath, fullPath, realPath: null }
}
const realFullPath = await fs.realpath(fullPath)
const realRoot = await fs.realpath(notebookRoot)
if (!realFullPath.startsWith(realRoot)) {
throw new AccessDeniedError('Symbolic link escapes notebook root')
}
return { safeRelPath, fullPath, realPath: realFullPath }
}
export const validateFileName = (name: string): boolean => {
if (!name || name.length === 0) return false
if (name.length > 255) return false
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/
if (invalidChars.test(name)) return false
if (WINDOWS_RESERVED_NAMES.test(name)) return false
if (name.startsWith('.') || name.endsWith('.')) return false
return true
}

34
api/utils/response.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { Response } from 'express'
import type { ApiResponse } from '../../shared/types.js'
/**
* Send a successful API response
*/
export const successResponse = <T>(res: Response, data: T, statusCode: number = 200): void => {
const response: ApiResponse<T> = {
success: true,
data,
}
res.status(statusCode).json(response)
}
/**
* Send an error API response
*/
export const errorResponse = (
res: Response,
statusCode: number,
code: string,
message: string,
details?: unknown
): void => {
const response: ApiResponse<null> = {
success: false,
error: {
code,
message,
details: process.env.NODE_ENV === 'production' ? undefined : details,
},
}
res.status(statusCode).json(response)
}

23
api/utils/tempDir.ts Normal file
View File

@@ -0,0 +1,23 @@
import { existsSync, mkdirSync } from 'fs'
import path from 'path'
import { PATHS } from '../config/paths.js'
let tempDir: string | null = null
export const getTempDir = (): string => {
if (!tempDir) {
tempDir = PATHS.TEMP_ROOT
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true })
}
}
return tempDir
}
export const getTempFilePath = (filename: string): string => {
return path.join(getTempDir(), filename)
}
export const ensureTempDir = (): string => {
return getTempDir()
}