From 7e7648383e5e69a4c02524996d184c95f8855875 Mon Sep 17 00:00:00 2001 From: PingChuan <1259732256@qq.com> Date: Fri, 21 Nov 2025 11:04:14 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=90=88=E5=90=8C=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=88=97=E8=A1=A8=E9=A2=84=E8=A7=88=E5=AE=8C=E6=88=90?= =?UTF-8?q?Collabora=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/collabora/CollaboraViewer.tsx | 18 -- app/config/api-config.ts | 8 +- app/lib/collabora/config.server.ts | 125 -------- app/lib/collabora/wopi.server.ts | 258 ----------------- app/routes/api.collabora.config.tsx | 15 +- app/routes/api.collabora.wopi.files.$.tsx | 111 ++++++++ .../api.collabora.wopi.files.$fileId.tsx | 2 +- app/services/collabora.config.server.ts | 127 +++++++++ app/services/collabora.wopi.server.ts | 269 ++++++++++++++++++ .../appspecific/com.chrome.devtools.json | 3 + 10 files changed, 527 insertions(+), 409 deletions(-) delete mode 100644 app/lib/collabora/config.server.ts delete mode 100644 app/lib/collabora/wopi.server.ts create mode 100644 app/routes/api.collabora.wopi.files.$.tsx create mode 100644 app/services/collabora.config.server.ts create mode 100644 app/services/collabora.wopi.server.ts create mode 100644 public/.well-known/appspecific/com.chrome.devtools.json diff --git a/app/components/collabora/CollaboraViewer.tsx b/app/components/collabora/CollaboraViewer.tsx index 60189cf..a26e3e1 100644 --- a/app/components/collabora/CollaboraViewer.tsx +++ b/app/components/collabora/CollaboraViewer.tsx @@ -86,24 +86,6 @@ export function CollaboraViewer({ title={`Collabora Online - ${config.fileName}`} sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals" /> - - {/* 调试信息(开发环境) */} - {process.env.NODE_ENV === 'development' && ( -
-
- 文档状态: {isDocumentLoaded ? '已加载' : '加载中...'} -
-
- 模式: {config.mode === 'edit' ? '编辑' : '只读'} -
-
- 文件: {config.fileName} -
-
- 用户: {userName} ({userId}) -
-
- )} ); } diff --git a/app/config/api-config.ts b/app/config/api-config.ts index f9425ff..69f1c7e 100644 --- a/app/config/api-config.ts +++ b/app/config/api-config.ts @@ -115,11 +115,11 @@ const portConfigs: Record> = { const configs: Record = { // 开发环境 development: { - baseUrl: 'http://127.0.0.1:8073', // FastAPI后端(包含/dify代理) - documentUrl: 'http://127.0.0.1:8073/docauditai/', - uploadUrl: 'http://127.0.0.1:8073/admin/documents', + baseUrl: 'http://172.16.0.78:8073', // FastAPI后端(包含/dify代理) + documentUrl: 'http://172.16.0.78:8073/docauditai/', + uploadUrl: 'http://172.16.0.78:8073/admin/documents', collaboraUrl: 'http://172.16.0.81:9980', - appUrl: 'http://10.79.97.17:51703', + appUrl: 'http://172.16.0.78:51703', oauth: { serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 clientId: 'none', diff --git a/app/lib/collabora/config.server.ts b/app/lib/collabora/config.server.ts deleted file mode 100644 index ae146f0..0000000 --- a/app/lib/collabora/config.server.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Collabora Online 配置生成服务 - * - * 职责: - * - 生成 Collabora iframe URL - * - 生成 WOPI access token - * - 构建完整的 Collabora 配置 - * - * @encoding UTF-8 - */ - -import { COLLABORA_URL, APP_URL } from '~/config/api-config'; -import { WopiService } from './wopi.server'; -import type { CollaboraConfig } from '~/components/collabora/types'; - -/** - * Collabora 配置生成参数 - */ -export interface GenerateConfigParams { - fileId: string; - mode: 'view' | 'edit'; - userId: string; - userName: string; -} - -/** - * 生成 Collabora 配置 - * @param params - 配置参数 - * @returns Collabora 配置对象 - */ -export async function generateCollaboraConfig( - params: GenerateConfigParams -): Promise { - const { fileId, mode, userId, userName } = params; - - // 创建 WOPI 服务实例 - const wopiService = new WopiService(); - - // 生成 WOPI access token(2 小时有效期) - const accessToken = wopiService.generateAccessToken( - { - fileId, - mode, - userId, - userName, - }, - 7200 // 2 小时 - ); - - // 构建 WOPI Src URL - const wopiSrc = `${APP_URL}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`; - - // 构建 Collabora iframe URL - const iframeUrl = buildCollaboraIframeUrl({ - collaboraUrl: COLLABORA_URL, - wopiSrc, - accessToken, - mode, - }); - - // 提取文件名 - const fileName = fileId.split('/').pop() || 'document.docx'; - - return { - iframeUrl, - accessToken, - fileName, - fileId, - collaboraUrl: COLLABORA_URL, - wopiSrc, - mode, - }; -} - -/** - * 构建 Collabora iframe URL - * @param params - URL 构建参数 - * @returns Collabora iframe URL - */ -function buildCollaboraIframeUrl(params: { - collaboraUrl: string; - wopiSrc: string; - accessToken: string; - mode: 'view' | 'edit'; -}): string { - const { collaboraUrl, wopiSrc, accessToken, mode } = params; - - // Collabora iframe 基础 URL - // fa80579 是 Collabora 的版本号标识,实际部署时可能需要调整 - const baseUrl = `${collaboraUrl}/browser/fa80579/cool.html`; - - const url = new URL(baseUrl); - - // 设置 WOPI Src - url.searchParams.set('WOPISrc', wopiSrc); - - // 设置 access token - url.searchParams.set('access_token', accessToken); - - // 设置 token 过期时间(毫秒) - url.searchParams.set('access_token_ttl', '7200000'); // 2 小时 - - // UI 定制参数 - const uiDefaults = [ - 'UIMode=compact', // 紧凑模式 - 'TextRuler=false', // 隐藏标尺 - 'TextStatusbar=false', // 隐藏状态栏 - 'TextSidebar=false', // 隐藏侧边栏 - 'SavedUIState=false', // 不保存 UI 状态 - ].join(';'); - url.searchParams.set('ui_defaults', uiDefaults); - - // 其他 UI 参数 - url.searchParams.set('closebutton', '0'); // 隐藏关闭按钮 - url.searchParams.set('revisionhistory', 'false'); // 禁用修订历史 - url.searchParams.set('lang', 'zh-CN'); // 设置语言为中文 - - // 根据模式设置权限 - if (mode === 'view') { - // 只读模式:通过 URL 参数限制权限 - url.searchParams.set('permission', 'readonly'); - } - - return url.toString(); -} diff --git a/app/lib/collabora/wopi.server.ts b/app/lib/collabora/wopi.server.ts deleted file mode 100644 index bd75b8b..0000000 --- a/app/lib/collabora/wopi.server.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * 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; - 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; - }, 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, - 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(/^\//, ''); - } - - /** - * 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 请求) - const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`; - - try { - const response = await fetch(fileUrl, { - method: 'HEAD', - }); - - if (!response.ok) { - throw new Error(`文件不存在: ${sanitizedFileId}`); - } - - 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: true, - - // 文件最后修改时间 - 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 - this.verifyAccessToken(accessToken, fileId); - - // 清理文件路径 - const sanitizedFileId = this.sanitizeFileId(fileId); - - // 通过 FastAPI 代理获取文件内容 - const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`; - - try { - const response = await fetch(fileUrl); - - 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', - }, - 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; - } - } -} diff --git a/app/routes/api.collabora.config.tsx b/app/routes/api.collabora.config.tsx index baa24f9..09b9277 100644 --- a/app/routes/api.collabora.config.tsx +++ b/app/routes/api.collabora.config.tsx @@ -11,7 +11,7 @@ import { type LoaderFunctionArgs, json } from '@remix-run/node'; import { getUserSession } from '~/api/login/auth.server'; -import { generateCollaboraConfig } from '~/lib/collabora/config.server'; +import { generateCollaboraConfig } from '~/services/collabora.config.server'; /** * GET /api/collabora/config @@ -24,8 +24,8 @@ import { generateCollaboraConfig } from '~/lib/collabora/config.server'; */ export async function loader({ request }: LoaderFunctionArgs) { try { - // 获取用户会话信息 - const { userInfo } = await getUserSession(request); + // 获取用户会话信息和 frontendJWT + const { userInfo, frontendJWT } = await getUserSession(request); // 解析查询参数 const url = new URL(request.url); @@ -42,12 +42,21 @@ export async function loader({ request }: LoaderFunctionArgs) { ); } + // 验证 frontendJWT + if (!frontendJWT) { + return json( + { error: '用户未认证' }, + { status: 401 } + ); + } + // 生成 Collabora 配置 const config = await generateCollaboraConfig({ fileId, mode, userId, userName, + frontendJWT, }); return json(config); diff --git a/app/routes/api.collabora.wopi.files.$.tsx b/app/routes/api.collabora.wopi.files.$.tsx new file mode 100644 index 0000000..e976dac --- /dev/null +++ b/app/routes/api.collabora.wopi.files.$.tsx @@ -0,0 +1,111 @@ +/** + * WOPI 协议 API 路由(Splat路由,支持多级路径) + * + * 功能: + * - CheckFileInfo: GET /api/collabora/wopi/files/{...fileId} + * - GetFile: GET /api/collabora/wopi/files/{...fileId}/contents + * - PutFile: POST /api/collabora/wopi/files/{...fileId}/contents + * + * 注意:使用splat路由($)匹配多级文件路径,如 documents/mz/合同文档/2025/test.docx + * + * @encoding UTF-8 + */ + +import { type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node'; +import { WopiService } from '../services/collabora.wopi.server'; + +const wopiService = new WopiService(); + +/** + * GET 请求处理 + * - 无 /contents 后缀 → CheckFileInfo + * - 有 /contents 后缀 → GetFile + */ +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const url = new URL(request.url); + const accessToken = url.searchParams.get('access_token'); + + if (!accessToken) { + return new Response('访问令牌缺失', { status: 401 }); + } + + // 获取文件 ID(使用 splat 参数 '*') + let fileId = params['*'] || ''; + + // 判断是否是 GetFile 请求(路径以 /contents 结尾) + const isContentsRequest = url.pathname.endsWith('/contents'); + + // 如果是 GetFile 请求,需要移除路径末尾的 /contents + if (isContentsRequest && fileId.endsWith('/contents')) { + fileId = fileId.slice(0, -9); // 移除 '/contents' + } + + if (isContentsRequest) { + // GetFile: 返回文件内容 + const { buffer, metadata } = await wopiService.getFile(fileId, accessToken); + + return new Response(buffer, { + headers: { + 'Content-Type': metadata.contentType, + 'Content-Length': metadata.size.toString(), + 'Content-Disposition': 'inline', + }, + }); + } + + // CheckFileInfo: 返回文件元数据 + const checkFileInfo = await wopiService.checkFileInfo(fileId, accessToken); + + // 注意:CheckFileInfo 必须返回纯 JSON,不能使用 Result.success() 包装 + return Response.json(checkFileInfo); + } catch (error) { + console.error('WOPI GET 失败:', error); + return new Response( + error instanceof Error ? error.message : 'Internal server error', + { status: 500 } + ); + } +} + +/** + * POST 请求处理 + * - PutFile: 保存文件内容 + */ +export async function action({ request, params }: ActionFunctionArgs) { + try { + const url = new URL(request.url); + const accessToken = url.searchParams.get('access_token'); + + if (!accessToken) { + return new Response('访问令牌缺失', { status: 401 }); + } + + // 获取文件 ID(使用 splat 参数 '*') + let fileId = params['*'] || ''; + + // 判断是否是 PutFile 请求(路径以 /contents 结尾) + const isContentsRequest = url.pathname.endsWith('/contents'); + + if (!isContentsRequest) { + return new Response('PutFile 必须使用 /contents 路径', { status: 400 }); + } + + // 移除路径末尾的 /contents + if (fileId.endsWith('/contents')) { + fileId = fileId.slice(0, -9); + } + + // PutFile: 保存文件 + const fileBuffer = await request.arrayBuffer(); + await wopiService.putFile(fileId, accessToken, fileBuffer); + + return new Response(null, { status: 200 }); + } catch (error) { + console.error('WOPI POST 失败:', error); + return new Response( + error instanceof Error ? error.message : 'Internal server error', + { status: 500 } + ); + } +} diff --git a/app/routes/api.collabora.wopi.files.$fileId.tsx b/app/routes/api.collabora.wopi.files.$fileId.tsx index 80a0538..f82150a 100644 --- a/app/routes/api.collabora.wopi.files.$fileId.tsx +++ b/app/routes/api.collabora.wopi.files.$fileId.tsx @@ -10,7 +10,7 @@ */ import { type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node'; -import { WopiService } from '~/lib/collabora/wopi.server'; +import { WopiService } from '../services/collabora.wopi.server'; const wopiService = new WopiService(); diff --git a/app/services/collabora.config.server.ts b/app/services/collabora.config.server.ts new file mode 100644 index 0000000..bfe3e55 --- /dev/null +++ b/app/services/collabora.config.server.ts @@ -0,0 +1,127 @@ +/** + * Collabora Online 配置生成服务 + * + * 职责: + * - 生成 Collabora iframe URL + * - 生成 WOPI access token + * - 构建完整的 Collabora 配置 + * + * @encoding UTF-8 + */ + +import type { CollaboraConfig } from '~/components/collabora/types'; +import { APP_URL, COLLABORA_URL } from '~/config/api-config'; +import { WopiService } from './collabora.wopi.server'; + +/** + * Collabora 配置生成参数 + */ +export interface GenerateConfigParams { + fileId: string; + mode: 'view' | 'edit'; + userId: string; + userName: string; + frontendJWT: string; // 用户的前端JWT token(用于调用FastAPI) +} + +/** + * 生成 Collabora 配置 + * @param params - 配置参数 + * @returns Collabora 配置对象 + */ +export async function generateCollaboraConfig( + params: GenerateConfigParams +): Promise { + const { fileId, mode, userId, userName, frontendJWT } = params; + + // 创建 WOPI 服务实例 + const wopiService = new WopiService(); + + // 生成 WOPI access token(2 小时有效期) + const accessToken = wopiService.generateAccessToken( + { + fileId, + mode, + userId, + userName, + frontendJWT, + }, + 7200 // 2 小时 + ); + + // 构建 WOPI Src URL + const wopiSrc = `${APP_URL}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`; + + // 构建 Collabora iframe URL + const iframeUrl = buildCollaboraIframeUrl({ + collaboraUrl: COLLABORA_URL, + wopiSrc, + accessToken, + mode, + }); + + // 提取文件名 + const fileName = fileId.split('/').pop() || 'document.docx'; + + return { + iframeUrl, + accessToken, + fileName, + fileId, + collaboraUrl: COLLABORA_URL, + wopiSrc, + mode, + }; +} + +/** + * 构建 Collabora iframe URL + * @param params - URL 构建参数 + * @returns Collabora iframe URL + */ +function buildCollaboraIframeUrl(params: { + collaboraUrl: string; + wopiSrc: string; + accessToken: string; + mode: 'view' | 'edit'; +}): string { + const { collaboraUrl, wopiSrc, accessToken, mode } = params; + + // Collabora iframe 基础 URL + // fa80579 是 Collabora 的版本号标识,实际部署时可能需要调整 + const baseUrl = `${collaboraUrl}/browser/fa80579/cool.html`; + + const url = new URL(baseUrl); + + // 设置 WOPI Src + url.searchParams.set('WOPISrc', wopiSrc); + + // 设置 access token + url.searchParams.set('access_token', accessToken); + + // 设置 token 过期时间(毫秒) + url.searchParams.set('access_token_ttl', '7200000'); // 2 小时 + + // UI 定制参数 + const uiDefaults = [ + 'UIMode=compact', // 紧凑模式 + 'TextRuler=false', // 隐藏标尺 + 'TextStatusbar=false', // 隐藏状态栏 + 'TextSidebar=false', // 隐藏侧边栏 + 'SavedUIState=false', // 不保存 UI 状态 + ].join(';'); + url.searchParams.set('ui_defaults', uiDefaults); + + // 其他 UI 参数 + url.searchParams.set('closebutton', '0'); // 隐藏关闭按钮 + url.searchParams.set('revisionhistory', 'false'); // 禁用修订历史 + url.searchParams.set('lang', 'zh-CN'); // 设置语言为中文 + + // 根据模式设置权限 + if (mode === 'view') { + // 只读模式:通过 URL 参数限制权限 + url.searchParams.set('permission', 'readonly'); + } + + return url.toString(); +} diff --git a/app/services/collabora.wopi.server.ts b/app/services/collabora.wopi.server.ts new file mode 100644 index 0000000..48f6236 --- /dev/null +++ b/app/services/collabora.wopi.server.ts @@ -0,0 +1,269 @@ +/** + * 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(/^\//, ''); + } + + /** + * 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 请求) + const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`; + + try { + const response = await fetch(fileUrl, { + method: 'HEAD', + headers: { + 'Authorization': `Bearer ${tokenData.frontendJWT}`, + }, + }); + + if (!response.ok) { + throw new Error(`文件不存在: ${sanitizedFileId}`); + } + + 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: true, + + // 文件最后修改时间 + 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; + } + } +} diff --git a/public/.well-known/appspecific/com.chrome.devtools.json b/public/.well-known/appspecific/com.chrome.devtools.json new file mode 100644 index 0000000..90fa5e8 --- /dev/null +++ b/public/.well-known/appspecific/com.chrome.devtools.json @@ -0,0 +1,3 @@ +{ + "version": "1.0" +} \ No newline at end of file