295 lines
9.0 KiB
TypeScript
295 lines
9.0 KiB
TypeScript
/**
|
||
* 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(/^\//, '');
|
||
}
|
||
|
||
/**
|
||
* 某些后端文件代理不支持 HEAD,这里先尝试 HEAD,遇到 405 再回退到 GET。
|
||
*/
|
||
private async probeFileMetadata(fileUrl: string, frontendJWT: string) {
|
||
const headers = {
|
||
'Authorization': `Bearer ${frontendJWT}`,
|
||
};
|
||
|
||
const headResponse = await fetch(fileUrl, {
|
||
method: 'HEAD',
|
||
headers,
|
||
});
|
||
|
||
if (headResponse.ok) {
|
||
return headResponse;
|
||
}
|
||
|
||
if (headResponse.status !== 405) {
|
||
throw new Error(`文件探测失败: ${headResponse.status}`);
|
||
}
|
||
|
||
const getResponse = await fetch(fileUrl, {
|
||
method: 'GET',
|
||
headers,
|
||
});
|
||
|
||
if (!getResponse.ok) {
|
||
throw new Error(`文件探测失败: ${getResponse.status}`);
|
||
}
|
||
|
||
return getResponse;
|
||
}
|
||
|
||
/**
|
||
* 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 返回 405,不能再直接据此判定“文件不存在”。
|
||
const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`;
|
||
|
||
try {
|
||
const response = await this.probeFileMetadata(fileUrl, tokenData.frontendJWT);
|
||
|
||
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: false,
|
||
|
||
// 文件最后修改时间
|
||
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;
|
||
}
|
||
}
|
||
}
|