feat:合同文档列表预览完成Collabora集成
This commit is contained in:
@@ -86,24 +86,6 @@ export function CollaboraViewer({
|
|||||||
title={`Collabora Online - ${config.fileName}`}
|
title={`Collabora Online - ${config.fileName}`}
|
||||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals"
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 调试信息(开发环境) */}
|
|
||||||
{process.env.NODE_ENV === 'development' && (
|
|
||||||
<div className="mt-2 p-2 bg-gray-100 text-xs rounded">
|
|
||||||
<div>
|
|
||||||
<strong>文档状态:</strong> {isDocumentLoaded ? '已加载' : '加载中...'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>模式:</strong> {config.mode === 'edit' ? '编辑' : '只读'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>文件:</strong> {config.fileName}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>用户:</strong> {userName} ({userId})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,11 +115,11 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
const configs: Record<string, ApiConfig> = {
|
const configs: Record<string, ApiConfig> = {
|
||||||
// 开发环境
|
// 开发环境
|
||||||
development: {
|
development: {
|
||||||
baseUrl: 'http://127.0.0.1:8073', // FastAPI后端(包含/dify代理)
|
baseUrl: 'http://172.16.0.78:8073', // FastAPI后端(包含/dify代理)
|
||||||
documentUrl: 'http://127.0.0.1:8073/docauditai/',
|
documentUrl: 'http://172.16.0.78:8073/docauditai/',
|
||||||
uploadUrl: 'http://127.0.0.1:8073/admin/documents',
|
uploadUrl: 'http://172.16.0.78:8073/admin/documents',
|
||||||
collaboraUrl: 'http://172.16.0.81:9980',
|
collaboraUrl: 'http://172.16.0.81:9980',
|
||||||
appUrl: 'http://10.79.97.17:51703',
|
appUrl: 'http://172.16.0.78:51703',
|
||||||
oauth: {
|
oauth: {
|
||||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
||||||
clientId: 'none',
|
clientId: 'none',
|
||||||
|
|||||||
@@ -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<CollaboraConfig> {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { type LoaderFunctionArgs, json } from '@remix-run/node';
|
import { type LoaderFunctionArgs, json } from '@remix-run/node';
|
||||||
import { getUserSession } from '~/api/login/auth.server';
|
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
|
* GET /api/collabora/config
|
||||||
@@ -24,8 +24,8 @@ import { generateCollaboraConfig } from '~/lib/collabora/config.server';
|
|||||||
*/
|
*/
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
try {
|
try {
|
||||||
// 获取用户会话信息
|
// 获取用户会话信息和 frontendJWT
|
||||||
const { userInfo } = await getUserSession(request);
|
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||||
|
|
||||||
// 解析查询参数
|
// 解析查询参数
|
||||||
const url = new URL(request.url);
|
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 配置
|
// 生成 Collabora 配置
|
||||||
const config = await generateCollaboraConfig({
|
const config = await generateCollaboraConfig({
|
||||||
fileId,
|
fileId,
|
||||||
mode,
|
mode,
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
|
frontendJWT,
|
||||||
});
|
});
|
||||||
|
|
||||||
return json(config);
|
return json(config);
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
|
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();
|
const wopiService = new WopiService();
|
||||||
|
|
||||||
|
|||||||
@@ -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<CollaboraConfig> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user