Files
leaudit-platform-frontend/app/lib/collabora/wopi.server.ts
T
2025-11-20 20:36:42 +08:00

259 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}
}