Skip to content

Commit 988d7d1

Browse files
committed
feat(webdav): 将默认上传模式切换为流式并提升Windows兼容性
fix(webdav): webdav已知bug feat(api,ui): 为文档/办公预览添加可配置的文档应用模板 refactor(fs,share): 调整分享和fs业务响应字段统一rawurl - 引入preview_document_apps JSON设置,用于映射文件扩展名→预览服务URL模板 - 用动态提供者解析替换硬编码的Microsoft/Google Office查看器
1 parent f382405 commit 988d7d1

35 files changed

Lines changed: 1092 additions & 679 deletions

File tree

.github/ISSUE_TEMPLATE/bug__report_EN.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ body:
2121
options:
2222
- Docker
2323
- Cloudflare Worker
24+
- Cloudflare Worker And Pages
2425
validations:
2526
required: true
2627

.github/ISSUE_TEMPLATE/bug_report.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ body:
2020
description: 采用的部署方式?
2121
options:
2222
- Docker
23-
- Cloudfare worker
24-
23+
- Cloudflare worker
24+
- Cloudflare Worker And Pages
2525
validations:
2626
required: true
2727

backend/src/cache/PreviewSettingsCache.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,29 @@ export class PreviewSettingsCache {
132132
return this.cache.get(key) || null;
133133
}
134134

135+
/**
136+
* 获取 DocumentApp 模板配置(JSON 对象)
137+
* @returns {Object|null} 解析后的配置对象
138+
*/
139+
getDocumentAppsConfig() {
140+
const raw = this.getSetting("preview_document_apps");
141+
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
142+
return null;
143+
}
144+
145+
try {
146+
const parsed = JSON.parse(raw);
147+
if (parsed && typeof parsed === "object") {
148+
return parsed;
149+
}
150+
} catch (e) {
151+
console.error("解析 preview_document_apps 配置失败,将视为未配置:", e);
152+
return null;
153+
}
154+
155+
return null;
156+
}
157+
135158
/**
136159
* 获取所有支持的扩展名(按类型分组)
137160
* @returns {Object} 按类型分组的扩展名对象

backend/src/constants/settings.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,15 +182,26 @@ export const DEFAULT_SETTINGS = {
182182
default_value: "pdf",
183183
},
184184

185+
preview_document_apps: {
186+
key: "preview_document_apps",
187+
type: SETTING_TYPES.TEXTAREA,
188+
group_id: SETTING_GROUPS.PREVIEW,
189+
help: "文档/Office 预览使用的 DocumentApp 模板配置,JSON 结构,按扩展名映射到各个预览服务的 URL 模板",
190+
options: null,
191+
sort_order: 7,
192+
flag: SETTING_FLAGS.PUBLIC,
193+
default_value: "",
194+
},
195+
185196
// WebDAV设置组
186197
webdav_upload_mode: {
187198
key: "webdav_upload_mode",
188199
type: SETTING_TYPES.SELECT,
189200
group_id: SETTING_GROUPS.WEBDAV,
190-
help: "WebDAV客户端的上传模式选择。单次上传适合小文件,分块上传适合大文件。",
201+
help: "WebDAV 客户端上传模式。流式上传大文件,单次上传适合小文件或兼容性场景。",
191202
options: JSON.stringify([
203+
{ value: "chunked", label: "流式上传" },
192204
{ value: "single", label: "单次上传" },
193-
{ value: "chunked", label: "分块上传" },
194205
]),
195206
sort_order: 1,
196207
flag: SETTING_FLAGS.PUBLIC,

backend/src/routes/fs/browse.js

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getVirtualDirectoryListing, isVirtualPath } from "../../storage/fs/util
66
import { createErrorResponse, getQueryBool, jsonOk } from "../../utils/common.js";
77
import { getEncryptionSecret } from "../../utils/environmentUtils.js";
88
import { LinkService } from "../../storage/link/LinkService.js";
9+
import { resolveDocumentPreview } from "../../services/documentPreviewService.js";
910

1011
export const registerBrowseRoutes = (router, helpers) => {
1112
const { getAccessibleMounts, getServiceParams, verifyPathPasswordToken } = helpers;
@@ -112,19 +113,40 @@ export const registerBrowseRoutes = (router, helpers) => {
112113
request: c.req.raw,
113114
});
114115

115-
const { officeSourceUrl, ...rest } = result;
116+
// 使用 LinkService 的决策结果作为 Link JSON 核心字段
117+
const rawUrl = link.url || null;
118+
const linkType = link.kind;
116119

117120
const responsePayload = {
118-
...rest,
119-
officeSourceUrl: officeSourceUrl || (link.kind === "direct" && link.url ? link.url : null),
120-
rawUrl: link.url || null,
121-
linkType: link.kind,
122-
isPresigned: link.isPresigned ?? false,
123-
origin: link.origin || "default",
124-
expiresAt: link.expiresAt || null,
121+
...result,
122+
rawUrl,
123+
linkType,
125124
};
126125

127-
return jsonOk(c, responsePayload, "获取文件信息成功");
126+
const documentPreview = await resolveDocumentPreview(
127+
{
128+
type: responsePayload.type,
129+
typeName: responsePayload.typeName,
130+
mimetype: responsePayload.mimetype,
131+
filename: responsePayload.name,
132+
name: responsePayload.name,
133+
size: responsePayload.size,
134+
},
135+
{
136+
rawUrl,
137+
linkType,
138+
use_proxy: responsePayload.use_proxy ?? 0,
139+
},
140+
);
141+
142+
return jsonOk(
143+
c,
144+
{
145+
...responsePayload,
146+
documentPreview,
147+
},
148+
"获取文件信息成功",
149+
);
128150
});
129151

130152
router.get("/api/fs/download", async (c) => {
@@ -208,9 +230,6 @@ export const registerBrowseRoutes = (router, helpers) => {
208230
const responsePayload = {
209231
rawUrl: link.url,
210232
linkType: link.kind,
211-
isPresigned: link.isPresigned ?? false,
212-
origin: link.origin || "default",
213-
expiresAt: link.expiresAt || null,
214233
};
215234

216235
return jsonOk(c, responsePayload, "获取文件直链成功");
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* 文档预览决策服务
3+
* 负责基于文件元信息与 Link JSON 生成 DocumentPreviewResult
4+
*/
5+
6+
import { FILE_TYPES } from "../constants/index.js";
7+
import previewSettingsCache from "../cache/PreviewSettingsCache.js";
8+
import { getFileExtension } from "../utils/fileTypeDetector.js";
9+
10+
/**
11+
* 解析文件类型是否属于 Office/文档范畴
12+
* @param {any} fileMeta - 文件元信息(至少包含 type/typeName/mimetype/filename)
13+
* @returns {boolean}
14+
*/
15+
function isOfficeLike(fileMeta) {
16+
if (!fileMeta) return false;
17+
18+
const typeCode = fileMeta.type;
19+
if (typeCode === FILE_TYPES.OFFICE || typeCode === FILE_TYPES.DOCUMENT) {
20+
return true;
21+
}
22+
23+
const typeName = (fileMeta.typeName || "").toString().toLowerCase();
24+
if (typeName === "office" || typeName === "document") {
25+
return true;
26+
}
27+
28+
const mimetype = (fileMeta.mimetype || "").toLowerCase();
29+
if (
30+
mimetype.includes("officedocument") ||
31+
mimetype.includes("ms-excel") ||
32+
mimetype.includes("ms-powerpoint") ||
33+
mimetype.includes("msword") ||
34+
mimetype === "application/rtf" ||
35+
mimetype === "application/pdf"
36+
) {
37+
return true;
38+
}
39+
40+
return false;
41+
}
42+
43+
/**
44+
* 统一的 DocumentPreview 决策入口
45+
* @param {Object} fileMeta - 文件元信息(type/typeName/mimetype/filename/size 等)
46+
* @param {Object} linkJson - Link JSON 视角下的链接信息(rawUrl/linkType/use_proxy 等)
47+
* @returns {Promise<{providers?: Record<string,string>}>}
48+
*/
49+
export async function resolveDocumentPreview(fileMeta, linkJson) {
50+
const baseResult = {};
51+
52+
// 非 Office/文档类型直接拒绝
53+
if (!isOfficeLike(fileMeta)) {
54+
return baseResult;
55+
}
56+
57+
const link = linkJson || {};
58+
const rawUrl = link.rawUrl || null;
59+
60+
// 基于扩展名与 preview_document_apps 配置选择 DocumentApp 模板
61+
const filename = fileMeta.filename || "";
62+
const extension = getFileExtension(filename);
63+
64+
const appsConfig = previewSettingsCache.getDocumentAppsConfig();
65+
if (!appsConfig || !extension) {
66+
console.warn(
67+
"[DocumentPreview] 无有效 DocumentApp 配置或扩展名为空",
68+
JSON.stringify({
69+
filename,
70+
extension,
71+
hasConfig: !!appsConfig,
72+
configKeys: appsConfig ? Object.keys(appsConfig) : [],
73+
}),
74+
);
75+
return baseResult;
76+
}
77+
78+
const matchedEntry = findMatchedDocumentAppEntry(appsConfig, extension, filename);
79+
if (!matchedEntry) {
80+
console.warn(
81+
"[DocumentPreview] 未找到匹配的 DocumentApp 条目",
82+
JSON.stringify({
83+
filename,
84+
extension,
85+
configKeys: Object.keys(appsConfig || {}),
86+
}),
87+
);
88+
return baseResult;
89+
}
90+
91+
const providers = buildProvidersFromTemplate(matchedEntry.providers, {
92+
url: rawUrl,
93+
name: filename,
94+
});
95+
96+
console.log(
97+
"[DocumentPreview] 已生成 providers",
98+
JSON.stringify({
99+
filename,
100+
extension,
101+
providerKeys: Object.keys(providers),
102+
}),
103+
);
104+
105+
const providerKeys = Object.keys(providers);
106+
if (!providerKeys.length) {
107+
return baseResult;
108+
}
109+
110+
return {
111+
providers,
112+
};
113+
}
114+
115+
/**
116+
* 在 DocumentApp 配置中根据扩展名/文件名查找匹配条目
117+
* @param {Object} appsConfig
118+
* @param {string} extension
119+
* @param {string} filename
120+
* @returns {{providers: Object}|null}
121+
*/
122+
function findMatchedDocumentAppEntry(appsConfig, extension, filename) {
123+
const ext = extension.toLowerCase();
124+
125+
for (const [pattern, providersConfig] of Object.entries(appsConfig)) {
126+
if (!providersConfig || typeof providersConfig !== "object") continue;
127+
128+
let matched = false;
129+
130+
if (pattern.startsWith("/") && pattern.endsWith("/")) {
131+
// 正则匹配:用于高级场景(例如按文件名匹配)
132+
const body = pattern.slice(1, -1);
133+
try {
134+
const regex = new RegExp(body);
135+
matched = regex.test(filename);
136+
} catch (e) {
137+
console.warn("preview_document_apps 中正则模式无效,已跳过:", pattern, e);
138+
}
139+
} else {
140+
// 扩展名列表匹配
141+
const exts = pattern
142+
.split(",")
143+
.map((p) => p.trim().toLowerCase())
144+
.filter((p) => p.length > 0);
145+
matched = exts.includes(ext);
146+
}
147+
148+
if (!matched) continue;
149+
150+
// 归一化 providersConfig,支持 string 或 object 形式
151+
const normalizedProviders = {};
152+
153+
for (const [providerKey, cfg] of Object.entries(providersConfig)) {
154+
if (!cfg) continue;
155+
156+
if (typeof cfg === "string") {
157+
normalizedProviders[providerKey] = {
158+
urlTemplate: cfg,
159+
};
160+
} else if (typeof cfg === "object") {
161+
normalizedProviders[providerKey] = {
162+
urlTemplate: cfg.urlTemplate || "",
163+
};
164+
}
165+
}
166+
167+
return { providers: normalizedProviders };
168+
}
169+
170+
return null;
171+
}
172+
173+
/**
174+
* 根据模板与变量构造 providers 映射
175+
* @param {Object} providersConfig
176+
* @param {{url: string|null, name: string}} vars
177+
* @returns {Record<string,string>}
178+
*/
179+
function buildProvidersFromTemplate(providersConfig, vars) {
180+
const result = {};
181+
182+
const url = vars.url || "";
183+
const name = vars.name || "";
184+
185+
const valueMap = {
186+
$url: url,
187+
$name: name,
188+
$e_url: url ? encodeURIComponent(url) : "",
189+
};
190+
191+
for (const [providerKey, cfg] of Object.entries(providersConfig)) {
192+
if (!cfg || !cfg.urlTemplate) continue;
193+
let rendered = cfg.urlTemplate;
194+
195+
rendered = rendered.replace(/\$e_url|\$url|\$name/g, (token) => valueMap[token] ?? "");
196+
197+
if (rendered) {
198+
result[providerKey] = rendered;
199+
}
200+
}
201+
202+
return result;
203+
}

0 commit comments

Comments
 (0)