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