Initial commit
This commit is contained in:
0
api/core/.gitkeep
Normal file
0
api/core/.gitkeep
Normal file
32
api/core/events/routes.ts
Normal file
32
api/core/events/routes.ts
Normal 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
280
api/core/files/routes.ts
Normal 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
97
api/core/search/routes.ts
Normal 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
|
||||
54
api/core/settings/routes.ts
Normal file
54
api/core/settings/routes.ts
Normal 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
100
api/core/upload/routes.ts
Normal 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
|
||||
Reference in New Issue
Block a user