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

0
api/core/.gitkeep Normal file
View File

32
api/core/events/routes.ts Normal file
View File

@@ -0,0 +1,32 @@
import express, { Request, Response } from 'express'
import type { ApiResponse } from '../../../shared/types.js'
import { eventBus } from '../../events/eventBus.js'
const router = express.Router()
router.get('/', (req: Request, res: Response) => {
if (process.env.VERCEL) {
const response: ApiResponse<null> = {
success: false,
error: { code: 'SSE_UNSUPPORTED', message: 'SSE在无服务器运行时中不受支持' },
}
return res.status(501).json(response)
}
const headers = {
'Content-Type': 'text/event-stream',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache'
}
res.writeHead(200, headers)
res.write(`data: ${JSON.stringify({ event: 'connected' })}\n\n`)
eventBus.addClient(res)
req.on('close', () => {
eventBus.removeClient(res)
})
})
export default router

280
api/core/files/routes.ts Normal file
View File

@@ -0,0 +1,280 @@
import express, { type Request, type Response } from 'express'
import fs from 'fs/promises'
import path from 'path'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import type { FileItemDTO, PathExistsDTO } from '../../../shared/types.js'
import { toPosixPath } from '../../../shared/utils/path.js'
import { pad2 } from '../../../shared/utils/date.js'
import { validateBody, validateQuery } from '../../middlewares/validate.js'
import {
listFilesQuerySchema,
contentQuerySchema,
rawQuerySchema,
saveFileSchema,
pathSchema,
renameSchema,
createDirSchema,
createFileSchema,
} from '../../schemas/index.js'
import {
NotFoundError,
NotADirectoryError,
BadRequestError,
AlreadyExistsError,
ForbiddenError,
ResourceLockedError,
InternalError,
isNodeError,
} from '../../../shared/errors/index.js'
import { logger } from '../../utils/logger.js'
const router = express.Router()
router.get(
'/',
validateQuery(listFilesQuerySchema),
asyncHandler(async (req: Request, res: Response) => {
const relPath = req.query.path as string
const { safeRelPath, fullPath } = resolveNotebookPath(relPath)
try {
await fs.access(fullPath)
} catch {
throw new NotFoundError('路径不存在')
}
const stats = await fs.stat(fullPath)
if (!stats.isDirectory()) {
throw new NotADirectoryError()
}
const files = await fs.readdir(fullPath)
const items = await Promise.all(
files.map(async (name): Promise<FileItemDTO | null> => {
const filePath = path.join(fullPath, name)
try {
const fileStats = await fs.stat(filePath)
return {
name,
type: fileStats.isDirectory() ? 'dir' : 'file',
size: fileStats.size,
modified: fileStats.mtime.toISOString(),
path: toPosixPath(path.join(safeRelPath, name)),
}
} catch {
return null
}
}),
)
const visibleItems = items.filter((i): i is FileItemDTO => i !== null && !i.name.startsWith('.'))
visibleItems.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name)
return a.type === 'dir' ? -1 : 1
})
successResponse(res, { items: visibleItems })
}),
)
router.get(
'/content',
validateQuery(contentQuerySchema),
asyncHandler(async (req: Request, res: Response) => {
const relPath = req.query.path as string
const { fullPath } = resolveNotebookPath(relPath)
const stats = await fs.stat(fullPath).catch(() => {
throw new NotFoundError('文件不存在')
})
if (!stats.isFile()) throw new BadRequestError('不是文件')
const content = await fs.readFile(fullPath, 'utf-8')
successResponse(res, {
content,
metadata: {
size: stats.size,
modified: stats.mtime.toISOString(),
},
})
}),
)
router.get(
'/raw',
validateQuery(rawQuerySchema),
asyncHandler(async (req: Request, res: Response) => {
const relPath = req.query.path as string
const { fullPath } = resolveNotebookPath(relPath)
const stats = await fs.stat(fullPath).catch(() => {
throw new NotFoundError('文件不存在')
})
if (!stats.isFile()) throw new BadRequestError('不是文件')
const ext = path.extname(fullPath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
}
const mimeType = mimeTypes[ext]
if (mimeType) res.setHeader('Content-Type', mimeType)
res.sendFile(fullPath)
}),
)
router.post(
'/save',
validateBody(saveFileSchema),
asyncHandler(async (req: Request, res: Response) => {
const { path: relPath, content } = req.body
const { fullPath } = resolveNotebookPath(relPath)
await fs.mkdir(path.dirname(fullPath), { recursive: true })
await fs.writeFile(fullPath, content, 'utf-8')
successResponse(res, null)
}),
)
router.delete(
'/delete',
validateBody(pathSchema),
asyncHandler(async (req: Request, res: Response) => {
const { path: relPath } = req.body
const { fullPath } = resolveNotebookPath(relPath)
await fs.stat(fullPath).catch(() => {
throw new NotFoundError('文件或目录不存在')
})
const { fullPath: rbDir } = resolveNotebookPath('RB')
await fs.mkdir(rbDir, { recursive: true })
const originalName = path.basename(fullPath)
const now = new Date()
const year = now.getFullYear()
const month = pad2(now.getMonth() + 1)
const day = pad2(now.getDate())
const timestamp = `${year}${month}${day}`
const newName = `${timestamp}_${originalName}`
const rbDestPath = path.join(rbDir, newName)
await fs.rename(fullPath, rbDestPath)
successResponse(res, null)
}),
)
router.post(
'/exists',
validateBody(pathSchema),
asyncHandler(async (req: Request, res: Response) => {
const { path: relPath } = req.body
const { fullPath } = resolveNotebookPath(relPath)
try {
const stats = await fs.stat(fullPath)
const type: PathExistsDTO['type'] = stats.isDirectory() ? 'dir' : stats.isFile() ? 'file' : null
successResponse<PathExistsDTO>(res, { exists: true, type })
} catch (err: unknown) {
if (isNodeError(err) && err.code === 'ENOENT') {
successResponse<PathExistsDTO>(res, { exists: false, type: null })
return
}
throw err
}
}),
)
router.post(
'/create/dir',
validateBody(createDirSchema),
asyncHandler(async (req: Request, res: Response) => {
const { path: relPath } = req.body
const { fullPath } = resolveNotebookPath(relPath)
try {
await fs.mkdir(fullPath, { recursive: true })
} catch (err: unknown) {
if (isNodeError(err)) {
if (err.code === 'EEXIST') {
throw new AlreadyExistsError('路径已存在')
}
if (err.code === 'EACCES') {
throw new ForbiddenError('没有权限创建目录')
}
}
throw err
}
successResponse(res, null)
}),
)
router.post(
'/create/file',
validateBody(createFileSchema),
asyncHandler(async (req: Request, res: Response) => {
const { path: relPath } = req.body
const { fullPath } = resolveNotebookPath(relPath)
await fs.mkdir(path.dirname(fullPath), { recursive: true })
try {
const fileName = path.basename(relPath, '.md')
const content = `# ${fileName}`
await fs.writeFile(fullPath, content, { encoding: 'utf-8', flag: 'wx' })
} catch (err: unknown) {
if (isNodeError(err)) {
if (err.code === 'EEXIST') throw new AlreadyExistsError('路径已存在')
if (err.code === 'EACCES') throw new ForbiddenError('没有权限创建文件')
}
throw err
}
successResponse(res, null)
}),
)
router.post(
'/rename',
validateBody(renameSchema),
asyncHandler(async (req: Request, res: Response) => {
const { oldPath, newPath } = req.body
const { fullPath: oldFullPath } = resolveNotebookPath(oldPath)
const { fullPath: newFullPath } = resolveNotebookPath(newPath)
await fs.mkdir(path.dirname(newFullPath), { recursive: true })
try {
await fs.rename(oldFullPath, newFullPath)
} catch (err: unknown) {
if (isNodeError(err)) {
if (err.code === 'ENOENT') {
throw new NotFoundError('文件不存在')
}
if (err.code === 'EEXIST') {
throw new AlreadyExistsError('路径已存在')
}
if (err.code === 'EPERM' || err.code === 'EACCES') {
throw new ForbiddenError('没有权限重命名文件或目录')
}
if (err.code === 'EBUSY') {
throw new ResourceLockedError('文件或目录正在使用中或被锁定')
}
}
logger.error('重命名错误:', err)
throw new InternalError('重命名文件或目录失败')
}
successResponse(res, null)
}),
)
export default router

97
api/core/search/routes.ts Normal file
View File

@@ -0,0 +1,97 @@
import express, { type Request, type Response } from 'express'
import fs from 'fs/promises'
import path from 'path'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import type { FileItemDTO } from '../../../shared/types.js'
import { toPosixPath } from '../../../shared/utils/path.js'
const router = express.Router()
router.post(
'/',
asyncHandler(async (req: Request, res: Response) => {
const { keywords } = req.body as { keywords?: string[] }
if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
successResponse(res, { items: [] })
return
}
const searchTerms = keywords.map(k => k.trim().toLowerCase()).filter(k => k.length > 0)
if (searchTerms.length === 0) {
successResponse(res, { items: [] })
return
}
const { fullPath: rootPath } = resolveNotebookPath('')
const results: FileItemDTO[] = []
const maxResults = 100
const searchDir = async (dir: string, relativeDir: string) => {
if (results.length >= maxResults) return
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (results.length >= maxResults) break
const entryPath = path.join(dir, entry.name)
const entryRelativePath = path.join(relativeDir, entry.name)
if (entry.name.startsWith('.') || entry.name === 'RB' || entry.name === 'node_modules') continue
if (entry.isDirectory()) {
await searchDir(entryPath, entryRelativePath)
} else if (entry.isFile()) {
const fileNameLower = entry.name.toLowerCase()
let contentLower = ''
let contentLoaded = false
const checkKeyword = async (term: string) => {
if (fileNameLower.includes(term)) return true
if (entry.name.toLowerCase().endsWith('.md')) {
if (!contentLoaded) {
try {
const content = await fs.readFile(entryPath, 'utf-8')
contentLower = content.toLowerCase()
contentLoaded = true
} catch {
return false
}
}
return contentLower.includes(term)
}
return false
}
let allMatched = true
for (const term of searchTerms) {
const matched = await checkKeyword(term)
if (!matched) {
allMatched = false
break
}
}
if (allMatched) {
results.push({
name: entry.name,
path: toPosixPath(entryRelativePath),
type: 'file',
size: 0,
modified: new Date().toISOString(),
})
}
}
}
}
await searchDir(rootPath, '')
successResponse(res, { items: results, limited: results.length >= maxResults })
}),
)
export default router

View File

@@ -0,0 +1,54 @@
import express, { type Request, type Response } from 'express'
import fs from 'fs/promises'
import path from 'path'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { NOTEBOOK_ROOT } from '../../config/paths.js'
import type { SettingsDTO } from '../../../shared/types.js'
const router = express.Router()
const getSettingsPath = () => path.join(NOTEBOOK_ROOT, '.config', 'settings.json')
router.get(
'/',
asyncHandler(async (req: Request, res: Response) => {
const settingsPath = getSettingsPath()
try {
const content = await fs.readFile(settingsPath, 'utf-8')
const settings = JSON.parse(content)
successResponse(res, settings)
} catch (error) {
successResponse(res, {})
}
}),
)
router.post(
'/',
asyncHandler(async (req: Request, res: Response) => {
const settings = req.body as SettingsDTO
const settingsPath = getSettingsPath()
const configDir = path.dirname(settingsPath)
try {
await fs.mkdir(configDir, { recursive: true })
let existingSettings = {}
try {
const content = await fs.readFile(settingsPath, 'utf-8')
existingSettings = JSON.parse(content)
} catch {
}
const newSettings = { ...existingSettings, ...settings }
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2), 'utf-8')
successResponse(res, newSettings)
} catch (error) {
throw error
}
}),
)
export default router

100
api/core/upload/routes.ts Normal file
View File

@@ -0,0 +1,100 @@
import express, { type Request, type Response } from 'express'
import fs from 'fs/promises'
import path from 'path'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import { getUniqueFilename, mimeToExt, validateImageBuffer, detectImageMimeType } from '../../utils/file.js'
import { NOTEBOOK_ROOT } from '../../config/paths.js'
import { toPosixPath } from '../../../shared/utils/path.js'
import { pad2, formatTimestamp } from '../../../shared/utils/date.js'
import { ValidationError, UnsupportedMediaTypeError } from '../../../shared/errors/index.js'
const router = express.Router()
const parseImageDataUrl = (dataUrl: string): { mimeType: string; base64Data: string } | null => {
const match = dataUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/)
if (!match) return null
const [, mimeType, base64Data] = match
return { mimeType, base64Data: base64Data.replace(/\s/g, '') }
}
router.post(
'/image',
asyncHandler(async (req: Request, res: Response) => {
const { image } = req.body as { image?: string }
if (!image) throw new ValidationError('需要图片数据')
const parsed = parseImageDataUrl(image)
if (!parsed) {
throw new ValidationError('无效的图片数据URL')
}
const ext = mimeToExt[parsed.mimeType]
if (!ext) {
throw new UnsupportedMediaTypeError('不支持的图片类型')
}
const buffer = Buffer.from(parsed.base64Data, 'base64')
validateImageBuffer(buffer, parsed.mimeType)
const detectedMimeType = detectImageMimeType(buffer)
if (!detectedMimeType || detectedMimeType !== parsed.mimeType) {
throw new ValidationError('图片内容类型不匹配或图片已损坏')
}
const now = new Date()
const year = now.getFullYear()
const month = pad2(now.getMonth() + 1)
const day = pad2(now.getDate())
const imagesSubDir = `images/${year}/${month}/${day}`
const { fullPath: imagesDirFullPath } = resolveNotebookPath(imagesSubDir)
await fs.mkdir(imagesDirFullPath, { recursive: true })
const baseName = formatTimestamp(now)
const filename = await getUniqueFilename(imagesDirFullPath, baseName, ext)
const relPath = `${imagesSubDir}/${filename}`
const { fullPath } = resolveNotebookPath(relPath)
await fs.writeFile(fullPath, buffer)
successResponse(res, { name: toPosixPath(relPath), path: toPosixPath(relPath) })
}),
)
router.post(
'/wallpaper',
asyncHandler(async (req: Request, res: Response) => {
const { image } = req.body as { image?: string }
if (!image) throw new ValidationError('需要图片数据')
const parsed = parseImageDataUrl(image)
if (!parsed) {
throw new ValidationError('无效的图片数据URL')
}
const allowedWallpaperTypes = ['image/png', 'image/jpeg', 'image/webp']
if (!allowedWallpaperTypes.includes(parsed.mimeType)) {
throw new UnsupportedMediaTypeError('壁纸只支持PNG、JPEG和WebP格式')
}
const buffer = Buffer.from(parsed.base64Data, 'base64')
validateImageBuffer(buffer, parsed.mimeType)
const detectedMimeType = detectImageMimeType(buffer)
if (!detectedMimeType || detectedMimeType !== parsed.mimeType) {
throw new ValidationError('图片内容类型不匹配或图片已损坏')
}
const configDir = path.join(NOTEBOOK_ROOT, '.config')
const backgroundPath = path.join(configDir, 'background.png')
await fs.mkdir(configDir, { recursive: true })
await fs.writeFile(backgroundPath, buffer)
successResponse(res, { message: '壁纸已更新' })
}),
)
export default router