Initial commit
This commit is contained in:
79
api/utils/__tests__/asyncHandler.test.ts
Normal file
79
api/utils/__tests__/asyncHandler.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
137
api/utils/__tests__/response.test.ts
Normal file
137
api/utils/__tests__/response.test.ts
Normal 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
10
api/utils/asyncHandler.ts
Normal 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
126
api/utils/file.ts
Normal 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
15
api/utils/logger.ts
Normal 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
106
api/utils/pathSafety.ts
Normal 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
34
api/utils/response.ts
Normal 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
23
api/utils/tempDir.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user