temp:临时备份,测试合并兼容性
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user