/** * WOPI (Web Application Open Platform Interface) 协议服务层 * * 职责: * - CheckFileInfo: 返回文件元数据 * - GetFile: 返回文件内容 * - PutFile: 保存文件内容 * * @encoding UTF-8 */ import jwt from 'jsonwebtoken'; import { DOCUMENT_URL } from '~/config/api-config'; /** * WOPI Access Token Payload */ export interface WopiTokenPayload { fileId: string; mode: 'view' | 'edit'; userId: string; userName: string; frontendJWT: string; // 用户的前端JWT token(用于调用FastAPI) iat: number; // 签发时间 exp: number; // 过期时间 } /** * WOPI 服务类 */ export class WopiService { private readonly jwtSecret: string; constructor() { const secret = process.env.JWT_SECRET; if (!secret) { throw new Error('JWT_SECRET environment variable is not set'); } this.jwtSecret = secret; } /** * 生成 WOPI access token * @param params - Token 参数 * @param expiresIn - 过期时间(秒),默认 2 小时 * @returns JWT token */ generateAccessToken(params: { fileId: string; mode: 'view' | 'edit'; userId: string; userName: string; frontendJWT: string; // 用户的前端JWT token }, expiresIn: number = 7200): string { const now = Math.floor(Date.now() / 1000); const payload: WopiTokenPayload = { fileId: params.fileId, mode: params.mode, userId: params.userId, userName: params.userName, frontendJWT: params.frontendJWT, iat: now, exp: now + expiresIn, }; return jwt.sign(payload, this.jwtSecret, { algorithm: 'HS256', }); } /** * 验证 WOPI access token * @param token - JWT token * @param fileId - 文件 ID(用于验证 token 中的 fileId 是否匹配) * @returns Token payload * @throws Error 如果 token 无效 */ private verifyAccessToken(token: string, fileId: string): WopiTokenPayload { try { const payload = jwt.verify(token, this.jwtSecret, { algorithms: ['HS256'], }) as WopiTokenPayload; // 验证文件 ID 是否匹配 if (payload.fileId !== fileId) { throw new Error('Token 文件 ID 不匹配'); } // 验证过期时间 const now = Math.floor(Date.now() / 1000); if (payload.exp && payload.exp < now) { throw new Error('Token 已过期'); } return payload; } catch (error) { console.error('WOPI token 验证失败:', error); throw new Error('Token 无效'); } } /** * 文件路径清理(防止目录遍历攻击) * @param fileId - 文件 ID * @returns 清理后的文件 ID */ private sanitizeFileId(fileId: string): string { // 移除 ../ 和绝对路径 return fileId.replace(/\.\./g, '').replace(/^\//, ''); } /** * 某些后端文件代理不支持 HEAD,这里先尝试 HEAD,遇到 405 再回退到 GET。 */ private async probeFileMetadata(fileUrl: string, frontendJWT: string) { const headers = { 'Authorization': `Bearer ${frontendJWT}`, }; const headResponse = await fetch(fileUrl, { method: 'HEAD', headers, }); if (headResponse.ok) { return headResponse; } if (headResponse.status !== 405) { throw new Error(`文件探测失败: ${headResponse.status}`); } const getResponse = await fetch(fileUrl, { method: 'GET', headers, }); if (!getResponse.ok) { throw new Error(`文件探测失败: ${getResponse.status}`); } return getResponse; } /** * CheckFileInfo - 返回文件元数据 * @param fileId - 文件路径(例如:contracts/test.docx) * @param accessToken - WOPI access token * @returns 文件元数据(WOPI CheckFileInfo 响应) */ async checkFileInfo(fileId: string, accessToken: string) { // 验证 token const tokenData = this.verifyAccessToken(accessToken, fileId); // 清理文件路径 const sanitizedFileId = this.sanitizeFileId(fileId); // 通过 FastAPI 代理获取文件元数据。 // 注意:当前后端文件路由对 HEAD 返回 405,不能再直接据此判定“文件不存在”。 const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`; try { const response = await this.probeFileMetadata(fileUrl, tokenData.frontendJWT); const contentLength = response.headers.get('Content-Length'); const lastModified = response.headers.get('Last-Modified'); const fileName = sanitizedFileId.split('/').pop() || 'document.docx'; // 返回 WOPI CheckFileInfo 响应 return { // 基本文件信息 BaseFileName: fileName, Size: contentLength ? parseInt(contentLength, 10) : 0, Version: lastModified || Date.now().toString(), // 用户信息 UserId: tokenData.userId, UserFriendlyName: tokenData.userName, // 文件权限 UserCanWrite: tokenData.mode === 'edit', UserCanNotWriteRelative: true, // Collabora 特定属性 EnableOwnerTermination: false, SupportsUpdate: tokenData.mode === 'edit', SupportsLocks: false, // UI 隐藏选项 HidePrintOption: false, HideSaveOption: false, HideExportOption: false, HideUserList: 'desktop', // 功能配置 DisableInactiveMessages: true, DisableAutoSave: false, // 文件最后修改时间 LastModifiedTime: lastModified || new Date().toISOString(), }; } catch (error) { console.error('CheckFileInfo 失败:', error); throw error; } } /** * GetFile - 返回文件内容 * @param fileId - 文件路径 * @param accessToken - WOPI access token * @returns 文件内容和元数据 */ async getFile(fileId: string, accessToken: string) { // 验证 token const tokenData = this.verifyAccessToken(accessToken, fileId); // 清理文件路径 const sanitizedFileId = this.sanitizeFileId(fileId); // 通过 FastAPI 代理获取文件内容 const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`; try { const response = await fetch(fileUrl, { headers: { 'Authorization': `Bearer ${tokenData.frontendJWT}`, }, }); if (!response.ok) { throw new Error(`获取文件失败: ${sanitizedFileId}`); } const buffer = Buffer.from(await response.arrayBuffer()); const contentType = response.headers.get('Content-Type') || 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; return { buffer, metadata: { contentType, size: buffer.length, }, }; } catch (error) { console.error('GetFile 失败:', error); throw error; } } /** * PutFile - 保存文件内容 * @param fileId - 文件路径 * @param accessToken - WOPI access token * @param fileBuffer - 文件内容 */ async putFile(fileId: string, accessToken: string, fileBuffer: ArrayBuffer) { // 验证 token const tokenData = this.verifyAccessToken(accessToken, fileId); // 检查是否有写入权限 if (tokenData.mode !== 'edit') { throw new Error('无写入权限'); } // 清理文件路径 const sanitizedFileId = this.sanitizeFileId(fileId); // 通过 FastAPI 代理上传文件 const uploadUrl = `${DOCUMENT_URL}${sanitizedFileId}`; try { const response = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'Authorization': `Bearer ${tokenData.frontendJWT}`, }, body: fileBuffer, }); if (!response.ok) { throw new Error(`保存文件失败: ${sanitizedFileId}`); } console.log(`PutFile 成功: ${sanitizedFileId}, Size: ${fileBuffer.byteLength} bytes`); } catch (error) { console.error('PutFile 失败:', error); throw error; } } }