0.2.2 onlyoffice修复

This commit is contained in:
liaibo
2026-01-17 12:38:04 +08:00
parent 19907bccdc
commit df2e9f5339
10 changed files with 349 additions and 893 deletions
+49 -1
View File
@@ -60,6 +60,39 @@ function buildUpstreamRequestHead(req, targetUrl) {
lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`);
const headers = req.headers || {};
const headerValue = (name) => {
const v = headers[name];
if (!v) return "";
return Array.isArray(v) ? v[0] : String(v);
};
// 说明:ONLYOFFICE 在反向代理下会依赖 X-Forwarded-* 推导“对外地址”,用于拼出 ws/wss 与缓存资源 URL。
// 这里尽量沿用上游(frp/nginx)注入的 x-forwarded-proto/host;若缺失,则回退到本机 http。
const originLike = headerValue("origin") || headerValue("referer") || "";
const originUrl = (() => {
try {
if (!originLike) return null;
return new URL(originLike);
} catch {
return null;
}
})();
const forwardedHostRaw =
headerValue("x-forwarded-host") || (originUrl ? originUrl.host : "") || headerValue("host") || "";
const forwardedProto =
(headerValue("x-forwarded-proto") || "").split(",")[0].trim() ||
(originUrl ? originUrl.protocol.replace(":", "") : "") ||
"http";
const forwardedPort = (() => {
const fromHeader = (headerValue("x-forwarded-port") || "").split(",")[0].trim();
if (fromHeader) return fromHeader;
const hostHasPort = forwardedHostRaw.includes(":") ? forwardedHostRaw.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
for (const [k, v] of Object.entries(headers)) {
if (!v) continue;
const key = String(k);
@@ -71,6 +104,12 @@ function buildUpstreamRequestHead(req, targetUrl) {
}
}
// 说明:补齐/覆盖 forward 信息,避免 ONLYOFFICE 返回指向内部端口的绝对 URL。
lines.push(`x-forwarded-host: ${forwardedHost}`);
lines.push(`x-forwarded-proto: ${forwardedProto}`);
lines.push(`x-forwarded-port: ${forwardedPort}`);
lines.push(`x-forwarded-prefix: ${ONLYOFFICE_PREFIX}`);
// 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。
lines.push(`Host: ${targetUrl.host}`);
lines.push("");
@@ -144,7 +183,16 @@ async function main() {
if (isOnlyOfficePath(req.url || "/")) {
try {
// eslint-disable-next-line no-console
console.log("[dev-server][onlyoffice-ws] upgrade", req.url);
console.log(
"[dev-server][onlyoffice-ws] upgrade",
req.url,
"host=",
req.headers.host,
"xfp=",
req.headers["x-forwarded-proto"],
"xfh=",
req.headers["x-forwarded-host"],
);
} catch {}
proxyOnlyOfficeUpgrade(req, socket, head);
return;
@@ -81,51 +81,57 @@ export async function POST(request: Request) {
}
if (isConvexEnabled()) {
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
const userId = String(process.env.DEV_USER_ID || "dev-user").trim() || "dev-user";
const client = getConvexHttpClient();
try {
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
const userId = String(process.env.DEV_USER_ID || "dev-user").trim() || "dev-user";
const client = getConvexHttpClient();
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
if (!asset) {
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
if (!asset) {
return NextResponse.json({ error: 1 });
}
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
if (!upstream.ok) {
return NextResponse.json({ error: 1 });
}
const buf = Buffer.from(await upstream.arrayBuffer());
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId });
if (!uploadUrl || typeof uploadUrl !== "string") {
return NextResponse.json({ error: 1 });
}
const uploadRes = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": asset.mime_type || "application/octet-stream" },
body: buf,
});
if (!uploadRes.ok) {
return NextResponse.json({ error: 1 });
}
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
const storageId = String(uploadJson?.storageId || "");
if (!storageId) {
return NextResponse.json({ error: 1 });
}
await client.mutation(api.mediaAssets.replaceStorageFromUpload, {
userId,
id: assetId,
storageId: storageId as any,
});
return NextResponse.json({ error: 0 });
} catch (error) {
// 说明:避免异常导致 ONLYOFFICE 重试/阻塞(例如 Convex 未部署新 mutation)。
console.error("[onlyoffice/callback] convex writeback failed:", error);
return NextResponse.json({ error: 1 });
}
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
if (!upstream.ok) {
return NextResponse.json({ error: 1 });
}
const buf = Buffer.from(await upstream.arrayBuffer());
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId });
if (!uploadUrl || typeof uploadUrl !== "string") {
return NextResponse.json({ error: 1 });
}
const uploadRes = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": asset.mime_type || "application/octet-stream" },
body: buf,
});
if (!uploadRes.ok) {
return NextResponse.json({ error: 1 });
}
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
const storageId = String(uploadJson?.storageId || "");
if (!storageId) {
return NextResponse.json({ error: 1 });
}
await client.mutation(api.mediaAssets.replaceStorageFromUpload, {
userId,
id: assetId,
storageId: storageId as any,
});
return NextResponse.json({ error: 0 });
}
const { data: asset, error: assetError } = await supabaseAdmin
@@ -1,5 +1,6 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
export const dynamic = "force-dynamic";
@@ -44,12 +45,23 @@ window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
var proxyPrefix = location.origin.replace(/\\/+$/, '') + '/onlyoffice-server';
var internal = {
'http://127.0.0.1:8081': true,
'http://localhost:8081': true
'http://localhost:8081': true,
// 说明:同上,兜底错误的 https://127.0.0.1:8081
'https://127.0.0.1:8081': true,
'https://localhost:8081': true
};
function rewrite(u) {
try {
var abs = new URL(u, location.origin);
var origin = abs.protocol + '//' + abs.host;
// 说明:外网 https 访问时,如果 ONLYOFFICE 错误生成了 http://<host>/onlyoffice-server/...
// 浏览器会以 Mixed Content 阻止请求;这里直接升级为 https。
if (location.protocol === 'https:' && abs.protocol === 'http:' && abs.host === location.host) {
if (abs.pathname === '/onlyoffice-server' || abs.pathname.indexOf('/onlyoffice-server/') === 0) {
abs.protocol = 'https:';
return abs.toString();
}
}
if (!internal[origin]) return u;
return proxyPrefix + abs.pathname + abs.search + abs.hash;
} catch (e) {
@@ -154,13 +166,57 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
// 说明:ONLYOFFICE 在被反向代理时,会根据 X-Forwarded-* 推导自身对外地址,
// 用于生成静态资源/缓存文件的 URL。若缺失这些信息,可能会返回指向内部端口
//(例如 http://127.0.0.1:8081/cache/...)的绝对 URL,导致浏览器跨域请求被 CORS 拦截。
headers.set("x-forwarded-host", incomingUrl.host);
headers.set("x-forwarded-proto", incomingUrl.protocol.replace(":", ""));
if (incomingUrl.port) {
headers.set("x-forwarded-port", incomingUrl.port);
} else {
headers.set("x-forwarded-port", incomingUrl.protocol === "https:" ? "443" : "80");
}
const runtimeCfg = getMnoteRuntimeConfig();
const xfp = (request.headers.get("x-forwarded-proto") || "").split(",")[0].trim();
const xfh = (request.headers.get("x-forwarded-host") || "").split(",")[0].trim();
const xfpPort = (request.headers.get("x-forwarded-port") || "").split(",")[0].trim();
const originLike =
request.headers.get("origin") ||
request.headers.get("referer") ||
"";
const originUrl = (() => {
try {
if (!originLike) return null;
return new URL(originLike);
} catch {
return null;
}
})();
const cloudflare = (() => {
try {
const raw = String(runtimeCfg.cloudflareAppOrigin || "").trim();
if (!raw) return null;
return new URL(raw);
} catch {
return null;
}
})();
const inferredProto =
xfp ||
(originUrl ? originUrl.protocol.replace(":", "") : "") ||
(cloudflare && cloudflare.host === incomingUrl.host ? cloudflare.protocol.replace(":", "") : "") ||
incomingUrl.protocol.replace(":", "");
const inferredHost =
xfh ||
(originUrl ? originUrl.host : "") ||
incomingUrl.host;
const inferredPort =
xfpPort ||
(originUrl ? originUrl.port : "") ||
(cloudflare && cloudflare.host === incomingUrl.host ? cloudflare.port : "") ||
incomingUrl.port ||
(inferredProto === "https" ? "443" : "80");
const proto = inferredProto || "http";
const host = inferredHost || incomingUrl.host;
const port = inferredPort || (proto === "https" ? "443" : "80");
headers.set("x-forwarded-host", host);
headers.set("x-forwarded-proto", proto);
headers.set("x-forwarded-port", port);
headers.set("x-forwarded-prefix", "/onlyoffice-server");
// 说明:避免上游返回 gzip 后被 Node fetch 自动解压,但仍带着 content-encoding
// 导致浏览器二次解压报 ERR_CONTENT_DECODING_FAILED。
@@ -150,7 +150,14 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
return `${window.location.origin.replace(/\/+$/, "")}${normalizedBase}`;
})();
const internalOrigins = new Set<string>(["http://127.0.0.1:8081", "http://localhost:8081"]);
const internalOrigins = new Set<string>([
"http://127.0.0.1:8081",
"http://localhost:8081",
// 说明:部分环境下 ONLYOFFICE 会错误拼出 https://127.0.0.1:8081 这类 URL
// 浏览器会报 ERR_SSL_PROTOCOL_ERROR(因为 8081 实际是 http)。这里也一起兜底重写。
"https://127.0.0.1:8081",
"https://localhost:8081",
]);
try {
if (onlyofficeBaseUrlDesktop) {
const u = new URL(onlyofficeBaseUrlDesktop);
@@ -166,6 +173,19 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
const rewriteUrl = (input: string) => {
try {
const u = new (win as any).URL(input, (win as any).location?.origin || window.location.origin);
// 说明:外网 https 访问时,若 ONLYOFFICE 错误生成 http://<host>/onlyoffice-server/... 会被 Mixed Content 阻止;
// 这里提前升级为 https,避免浏览器直接拦截请求。
try {
const loc = (win as any).location;
if (loc?.protocol === "https:" && u.protocol === "http:" && u.host === loc.host) {
if (u.pathname === "/onlyoffice-server" || u.pathname.startsWith("/onlyoffice-server/")) {
u.protocol = "https:";
return u.toString();
}
}
} catch {
// ignore
}
const origin = `${u.protocol}//${u.host}`;
if (!internalOrigins.has(origin)) return input;
return `${proxyPrefix}${u.pathname}${u.search}${u.hash}`;