| 1 | import fs from 'fs' |
| 2 | import path from 'path' |
| 3 | import dayjs from 'dayjs' |
| 4 | import { nanoid } from 'nanoid' |
| 5 | import type { UploadedAsset } from '@shared/generation' |
| 6 | import type { PPTDatabase } from '../../db/database' |
| 7 | import type { SessionProjectResolver } from './session-project' |
| 8 | |
| 9 | type UploadedFile = { path?: unknown; name?: unknown } |
| 10 | type UploadTarget = 'images' | 'videos' | 'docs' |
| 11 | |
| 12 | export type RuntimeLocalFiles = { |
| 13 | resolveStoragePath(): Promise<string> |
| 14 | normalizeSessionId(value: unknown): string | undefined |
| 15 | parsePathPayload( |
| 16 | payload: unknown, |
| 17 | preferredKey?: 'path' | 'htmlPath' |
| 18 | ): { filePath: string; sessionId?: string; hash?: string } |
| 19 | formatImagePathsForPrompt(imagePaths?: string[], videoPaths?: string[]): string |
| 20 | buildAssetTimestamp(): string |
| 21 | uploadSessionFiles( |
| 22 | sessionId: string, |
| 23 | files: UploadedFile[], |
| 24 | target: UploadTarget |
| 25 | ): Promise<UploadedAsset[]> |
| 26 | uploadImageAssets(sessionId: string, files: UploadedFile[]): Promise<UploadedAsset[]> |
| 27 | uploadMediaAssets(sessionId: string, files: UploadedFile[]): Promise<UploadedAsset[]> |
| 28 | resolveExistingFileRealPath(filePath: string): Promise<string> |
| 29 | resolveWritableFileRealPath(filePath: string): Promise<string> |
| 30 | resolveAllowedRoots(sessionId?: string): Promise<string[]> |
| 31 | assertPathInAllowedRoots(args: { |
| 32 | filePath: string |
| 33 | mode: 'read' | 'write' |
| 34 | sessionId?: string |
| 35 | htmlOnly?: boolean |
| 36 | }): Promise<string> |
| 37 | } |
| 38 | |
| 39 | const ALLOWED_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg']) |
| 40 | const ALLOWED_VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.ogg']) |
| 41 | const ALLOWED_DOC_EXTENSIONS = new Set(['.md', '.txt', '.text']) |
| 42 | const IMAGE_MIME_BY_EXT: Record<string, string> = { |
| 43 | '.png': 'image/png', |
| 44 | '.jpg': 'image/jpeg', |
| 45 | '.jpeg': 'image/jpeg', |
| 46 | '.webp': 'image/webp', |
| 47 | '.gif': 'image/gif', |
| 48 | '.svg': 'image/svg+xml' |
| 49 | } |
| 50 | const DOC_MIME_BY_EXT: Record<string, string> = { |
| 51 | '.md': 'text/markdown', |
| 52 | '.txt': 'text/plain', |
| 53 | '.text': 'text/plain' |
| 54 | } |
| 55 | const VIDEO_MIME_BY_EXT: Record<string, string> = { |
| 56 | '.mp4': 'video/mp4', |
| 57 | '.webm': 'video/webm', |
| 58 | '.ogg': 'video/ogg' |
| 59 | } |
| 60 | |
| 61 | export function createRuntimeLocalFiles(args: { |
| 62 | db: PPTDatabase |
| 63 | sessionProject: SessionProjectResolver |
| 64 | }): RuntimeLocalFiles { |
| 65 | const { db, sessionProject } = args |
| 66 | |
| 67 | const resolveStoragePath = async (): Promise<string> => { |
| 68 | const saved = await db.getSetting<string>('storage_path') |
| 69 | if (typeof saved === 'string' && saved.trim().length > 0) { |
| 70 | const normalized = saved.trim() |
| 71 | await db.setStoragePath(normalized) |
| 72 | return normalized |
| 73 | } |
| 74 | throw new Error('请先前往系统设置选择存储目录。') |
| 75 | } |
| 76 | |
| 77 | const normalizeSessionId = (value: unknown): string | undefined => { |
| 78 | if (typeof value !== 'string') return undefined |
| 79 | const trimmed = value.trim() |
| 80 | return trimmed.length > 0 ? trimmed : undefined |
| 81 | } |
| 82 | |
| 83 | const parsePathPayload = ( |
| 84 | payload: unknown, |
| 85 | preferredKey: 'path' | 'htmlPath' = 'path' |
| 86 | ): { filePath: string; sessionId?: string; hash?: string } => { |
| 87 | if (typeof payload === 'string') return { filePath: payload.trim() } |
| 88 | if (!payload || typeof payload !== 'object') return { filePath: '' } |
| 89 | const record = payload as Record<string, unknown> |
| 90 | const candidate = |
| 91 | typeof record[preferredKey] === 'string' |
| 92 | ? String(record[preferredKey]) |
| 93 | : typeof record.path === 'string' |
| 94 | ? String(record.path) |
| 95 | : typeof record.htmlPath === 'string' |
| 96 | ? String(record.htmlPath) |
| 97 | : '' |
| 98 | return { |
| 99 | filePath: candidate.trim(), |
| 100 | sessionId: normalizeSessionId(record.sessionId), |
| 101 | hash: typeof record.hash === 'string' ? record.hash : undefined |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | const formatImagePathsForPrompt = (imagePaths?: string[], videoPaths?: string[]): string => { |
| 106 | const validPaths = Array.isArray(imagePaths) |
| 107 | ? imagePaths |
| 108 | .map((item) => String(item || '').trim()) |
| 109 | .filter((item) => item.startsWith('./images/')) |
| 110 | .slice(0, 10) |
| 111 | : [] |
| 112 | const validVideoPaths = Array.isArray(videoPaths) |
| 113 | ? videoPaths |
| 114 | .map((item) => String(item || '').trim()) |
| 115 | .filter((item) => item.startsWith('./videos/')) |
| 116 | .slice(0, 10) |
| 117 | : [] |
| 118 | if (validPaths.length === 0 && validVideoPaths.length === 0) return '' |
| 119 | return [ |
| 120 | '', |
| 121 | validPaths.length > 0 ? '本次消息可用图片路径:' : '', |
| 122 | ...validPaths.map((imagePath, index) => `- ${index + 1}. ${imagePath}`), |
| 123 | validPaths.length > 0 ? '' : '', |
| 124 | validVideoPaths.length > 0 ? '本次消息可用视频路径:' : '', |
| 125 | ...validVideoPaths.map((videoPath, index) => `- ${index + 1}. ${videoPath}`), |
| 126 | validVideoPaths.length > 0 ? '' : '', |
| 127 | '素材使用规则:', |
| 128 | '- 如需使用图片或视频,请引用上面的相对路径。', |
| 129 | '- 禁止使用 file://、绝对路径或 base64。', |
| 130 | '- 不要重新引入远程资源,优先使用这些本地素材。', |
| 131 | '- 插入视频时必须使用 HTML <video> 标签,并包含 controls playsinline preload="metadata"。', |
| 132 | '- 视频默认不要添加 autoplay 或 muted,让用户点击控件后播放并保留声音;只有明确要求循环背景视频时才使用 muted/loop。' |
| 133 | ] |
| 134 | .filter(Boolean) |
| 135 | .join('\n') |
| 136 | } |
| 137 | |
| 138 | const buildAssetTimestamp = (): string => dayjs().format('YYYYMMDD-HHmmss') |
| 139 | |
| 140 | const uploadSessionFiles = async ( |
| 141 | sessionId: string, |
| 142 | files: UploadedFile[], |
| 143 | target: UploadTarget |
| 144 | ): Promise<UploadedAsset[]> => { |
| 145 | if (!sessionId) throw new Error('sessionId 不能为空') |
| 146 | if (files.length === 0) return [] |
| 147 | |
| 148 | const projectDir = await sessionProject.resolveSessionProjectDir(sessionId) |
| 149 | const targetRoot = path.join(projectDir, target) |
| 150 | await fs.promises.mkdir(targetRoot, { recursive: true }) |
| 151 | const uploadedAssets: UploadedAsset[] = [] |
| 152 | |
| 153 | for (const file of files) { |
| 154 | const sourcePath = typeof file.path === 'string' ? file.path.trim() : '' |
| 155 | if (!sourcePath) throw new Error('素材路径不能为空') |
| 156 | const stat = await fs.promises.stat(sourcePath) |
| 157 | if (!stat.isFile()) throw new Error(`素材不是文件: ${sourcePath}`) |
| 158 | if (stat.size > 20 * 1024 * 1024) throw new Error('单个素材不能超过 20MB') |
| 159 | |
| 160 | const ext = path.extname(sourcePath).toLowerCase() |
| 161 | if (target === 'images' && !ALLOWED_IMAGE_EXTENSIONS.has(ext)) { |
| 162 | throw new Error('暂只支持 png、jpg、jpeg、webp、gif、svg 图片素材') |
| 163 | } |
| 164 | if (target === 'docs' && !ALLOWED_DOC_EXTENSIONS.has(ext)) { |
| 165 | throw new Error('暂只支持 md、txt 文档素材') |
| 166 | } |
| 167 | if (target === 'videos' && !ALLOWED_VIDEO_EXTENSIONS.has(ext)) { |
| 168 | throw new Error('暂只支持 mp4、webm、ogg 视频素材') |
| 169 | } |
| 170 | |
| 171 | const originalName = |
| 172 | typeof file.name === 'string' && file.name.trim().length > 0 |
| 173 | ? file.name.trim() |
| 174 | : path.basename(sourcePath) |
| 175 | const id = nanoid(10) |
| 176 | const baseNameWithoutExt = sessionProject.toSafeAssetBaseName( |
| 177 | originalName.replace(/\.[^.]+$/, '') |
| 178 | ) |
| 179 | const fileName = `${baseNameWithoutExt}-${id}${ext}` |
| 180 | const targetPath = path.join(targetRoot, fileName) |
| 181 | if (!sessionProject.isPathInside(path.resolve(targetPath), targetRoot)) { |
| 182 | throw new Error('素材目标路径不合法') |
| 183 | } |
| 184 | await fs.promises.copyFile(sourcePath, targetPath) |
| 185 | |
| 186 | uploadedAssets.push({ |
| 187 | id, |
| 188 | fileName, |
| 189 | originalName, |
| 190 | relativePath: `./${target}/${fileName}`, |
| 191 | absolutePath: targetPath, |
| 192 | mimeType: |
| 193 | target === 'images' |
| 194 | ? IMAGE_MIME_BY_EXT[ext] || 'application/octet-stream' |
| 195 | : target === 'videos' |
| 196 | ? VIDEO_MIME_BY_EXT[ext] || 'application/octet-stream' |
| 197 | : DOC_MIME_BY_EXT[ext] || 'text/plain', |
| 198 | size: stat.size, |
| 199 | createdAt: Math.floor(Date.now() / 1000) |
| 200 | }) |
| 201 | } |
| 202 | |
| 203 | return uploadedAssets |
| 204 | } |
| 205 | |
| 206 | const uploadImageAssets = (sessionId: string, files: UploadedFile[]): Promise<UploadedAsset[]> => |
| 207 | uploadSessionFiles(sessionId, files, 'images') |
| 208 | |
| 209 | const uploadMediaAssets = async ( |
| 210 | sessionId: string, |
| 211 | files: UploadedFile[] |
| 212 | ): Promise<UploadedAsset[]> => { |
| 213 | const mediaAssets: UploadedAsset[] = [] |
| 214 | const imageFiles: UploadedFile[] = [] |
| 215 | const videoFiles: UploadedFile[] = [] |
| 216 | for (const file of files) { |
| 217 | const sourcePath = typeof file.path === 'string' ? file.path.trim() : '' |
| 218 | const ext = path.extname(sourcePath).toLowerCase() |
| 219 | if (ALLOWED_IMAGE_EXTENSIONS.has(ext)) { |
| 220 | imageFiles.push(file) |
| 221 | continue |
| 222 | } |
| 223 | if (ALLOWED_VIDEO_EXTENSIONS.has(ext)) { |
| 224 | videoFiles.push(file) |
| 225 | continue |
| 226 | } |
| 227 | throw new Error('暂只支持 png/jpg/webp/gif/svg 图片,或 mp4/webm/ogg 视频素材') |
| 228 | } |
| 229 | if (imageFiles.length > 0) { |
| 230 | mediaAssets.push(...(await uploadSessionFiles(sessionId, imageFiles, 'images'))) |
| 231 | } |
| 232 | if (videoFiles.length > 0) { |
| 233 | mediaAssets.push(...(await uploadSessionFiles(sessionId, videoFiles, 'videos'))) |
| 234 | } |
| 235 | return mediaAssets |
| 236 | } |
| 237 | |
| 238 | const resolveExistingFileRealPath = async (filePath: string): Promise<string> => { |
| 239 | const absolutePath = path.resolve(filePath) |
| 240 | if (!fs.existsSync(absolutePath)) throw new Error(`文件不存在: ${absolutePath}`) |
| 241 | const stat = await fs.promises.stat(absolutePath) |
| 242 | if (!stat.isFile()) throw new Error(`目标不是文件: ${absolutePath}`) |
| 243 | return fs.promises.realpath(absolutePath) |
| 244 | } |
| 245 | |
| 246 | const resolveWritableFileRealPath = async (filePath: string): Promise<string> => { |
| 247 | const absolutePath = path.resolve(filePath) |
| 248 | if (fs.existsSync(absolutePath)) { |
| 249 | const stat = await fs.promises.stat(absolutePath) |
| 250 | if (!stat.isFile()) throw new Error(`目标不是文件: ${absolutePath}`) |
| 251 | return fs.promises.realpath(absolutePath) |
| 252 | } |
| 253 | const parentDir = path.dirname(absolutePath) |
| 254 | if (!fs.existsSync(parentDir)) throw new Error(`目标目录不存在: ${parentDir}`) |
| 255 | const parentRealPath = await fs.promises.realpath(parentDir) |
| 256 | return path.join(parentRealPath, path.basename(absolutePath)) |
| 257 | } |
| 258 | |
| 259 | const resolveAllowedRoots = async (sessionId?: string): Promise<string[]> => { |
| 260 | const roots = new Set<string>() |
| 261 | const storagePath = await resolveStoragePath() |
| 262 | const storageRoot = fs.existsSync(storagePath) |
| 263 | ? await fs.promises.realpath(storagePath) |
| 264 | : path.resolve(storagePath) |
| 265 | roots.add(storageRoot) |
| 266 | |
| 267 | if (sessionId) { |
| 268 | const project = await db.getProject(sessionId) |
| 269 | const rootPath = typeof project?.root_path === 'string' ? project.root_path : '' |
| 270 | if (rootPath) { |
| 271 | const resolvedRootPath = fs.existsSync(rootPath) |
| 272 | ? await fs.promises.realpath(rootPath) |
| 273 | : path.resolve(rootPath) |
| 274 | roots.add(resolvedRootPath) |
| 275 | } |
| 276 | } |
| 277 | return [...roots] |
| 278 | } |
| 279 | |
| 280 | const assertPathInAllowedRoots = async (args: { |
| 281 | filePath: string |
| 282 | mode: 'read' | 'write' |
| 283 | sessionId?: string |
| 284 | htmlOnly?: boolean |
| 285 | }): Promise<string> => { |
| 286 | const { filePath, mode, sessionId, htmlOnly } = args |
| 287 | if (typeof filePath !== 'string' || filePath.trim().length === 0) { |
| 288 | throw new Error('文件路径不能为空') |
| 289 | } |
| 290 | const extension = path.extname(filePath).toLowerCase() |
| 291 | if (htmlOnly && extension !== '.html' && extension !== '.htm') { |
| 292 | throw new Error(`仅允许访问 HTML 文件,当前扩展名: ${extension || '(none)'}`) |
| 293 | } |
| 294 | const resolveSessionHtmlFallbackPath = async (): Promise<string | null> => { |
| 295 | if (mode !== 'read' || !sessionId) return null |
| 296 | if (extension !== '.html' && extension !== '.htm') return null |
| 297 | const fileName = path.basename(filePath) |
| 298 | if (!fileName) return null |
| 299 | const projectDir = await sessionProject.resolveSessionProjectDir(sessionId) |
| 300 | const fallbackPath = path.join(projectDir, fileName) |
| 301 | if (path.resolve(fallbackPath) === path.resolve(filePath)) return null |
| 302 | return fs.existsSync(fallbackPath) ? fallbackPath : null |
| 303 | } |
| 304 | |
| 305 | let targetPath: string |
| 306 | if (mode === 'read') { |
| 307 | try { |
| 308 | targetPath = await resolveExistingFileRealPath(filePath) |
| 309 | } catch (error) { |
| 310 | const fallbackPath = await resolveSessionHtmlFallbackPath() |
| 311 | if (!fallbackPath) throw error |
| 312 | targetPath = await resolveExistingFileRealPath(fallbackPath) |
| 313 | } |
| 314 | } else { |
| 315 | targetPath = await resolveWritableFileRealPath(filePath) |
| 316 | } |
| 317 | const allowedRoots = await resolveAllowedRoots(sessionId) |
| 318 | if (!allowedRoots.some((root) => sessionProject.isPathInside(targetPath, root))) { |
| 319 | throw new Error(`文件路径不在允许目录内: ${targetPath}`) |
| 320 | } |
| 321 | return targetPath |
| 322 | } |
| 323 | |
| 324 | return { |
| 325 | resolveStoragePath, |
| 326 | normalizeSessionId, |
| 327 | parsePathPayload, |
| 328 | formatImagePathsForPrompt, |
| 329 | buildAssetTimestamp, |
| 330 | uploadSessionFiles, |
| 331 | uploadImageAssets, |
| 332 | uploadMediaAssets, |
| 333 | resolveExistingFileRealPath, |
| 334 | resolveWritableFileRealPath, |
| 335 | resolveAllowedRoots, |
| 336 | assertPathInAllowedRoots |
| 337 | } |
| 338 | } |
| 339 |