feat: 完成 rust cutover phase 8 收口

This commit is contained in:
lix-2026
2026-04-15 20:01:12 +08:00
parent 822c730cd6
commit 98db79b301
104 changed files with 18777 additions and 3025 deletions
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
buildOnlyOfficeCallbackUrl,
buildOnlyOfficeForcesaveUrl,
buildOnlyOfficeOpenFileId,
docTypeFromExt,
resolveOnlyOfficeDocumentUrl,
} from "@/lib/onlyoffice/client-session";
describe("onlyoffice client session helpers", () => {
it("docTypeFromExt maps office extensions", () => {
expect(docTypeFromExt("docx")).toBe("word");
expect(docTypeFromExt("xlsx")).toBe("cell");
expect(docTypeFromExt("pptx")).toBe("slide");
expect(docTypeFromExt("pdf")).toBe("pdf");
});
it("resolveOnlyOfficeDocumentUrl wraps signed url with proxy", () => {
const result = resolveOnlyOfficeDocumentUrl({
effectiveFileUrl: "https://public.example.com/storage/v1/object/sign/documents/a.docx?token=abc",
proxyOrigin: "https://app.example.com",
storageHostOverride: "",
runtimeSupabaseUrl: "https://public.example.com",
useConvex: false,
});
expect(result).toContain("https://app.example.com/api/onlyoffice/proxy?u=");
});
it("buildOnlyOfficeCallbackUrl keeps asset and user", () => {
const result = buildOnlyOfficeCallbackUrl({
callbackOrigin: "http://host.docker.internal:3000",
proxyOrigin: "",
windowOrigin: "https://app.example.com",
assetId: "asset_1",
userId: "user_1",
});
expect(result).toBe(
"http://host.docker.internal:3000/api/onlyoffice/callback?assetId=asset_1&userId=user_1",
);
});
it("buildOnlyOfficeForcesaveUrl keeps key", () => {
const result = buildOnlyOfficeForcesaveUrl({
windowOrigin: "https://app.example.com",
assetId: "asset_1",
key: "doc_key_1",
});
expect(result).toBe(
"https://app.example.com/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key_1",
);
});
it("buildOnlyOfficeOpenFileId prefers asset id", () => {
expect(
buildOnlyOfficeOpenFileId({
assetId: "asset_1",
docKey: "doc_key_1",
resolvedFileUrl: "https://app.example.com/file.docx",
fileName: "file.docx",
}),
).toBe("asset_1");
});
});
@@ -0,0 +1,131 @@
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
export const hashOnlyOfficeKey = (input: string) => {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString();
};
export const base64UrlEncodeUtf8 = (input: string) => {
const bytes = new TextEncoder().encode(input);
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i] as number);
}
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
};
export const docTypeFromExt = (ext: string) => {
const word = ["doc", "docx", "odt", "rtf"];
const slide = ["ppt", "pptx", "odp"];
const sheet = ["xls", "xlsx", "ods", "csv"];
const pdf = ["pdf"];
if (word.includes(ext)) return "word";
if (slide.includes(ext)) return "slide";
if (sheet.includes(ext)) return "cell";
if (pdf.includes(ext)) return "pdf";
return "word";
};
export function resolveOnlyOfficeDocumentUrl(input: {
effectiveFileUrl: string;
proxyOrigin: string;
storageHostOverride: string;
runtimeSupabaseUrl: string;
useConvex: boolean;
}): string {
const { effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeSupabaseUrl, useConvex } = input;
if (!effectiveFileUrl) return "";
const isConvexStorageUrl = (() => {
try {
const u = new URL(effectiveFileUrl);
return u.pathname.startsWith("/api/storage/");
} catch {
return false;
}
})();
let base =
storageHostOverride || useConvex || isConvexStorageUrl
? effectiveFileUrl
: rewriteToPublicOrigin(effectiveFileUrl, runtimeSupabaseUrl);
try {
const raw = new URL(base);
let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
const isLocalHost =
raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";
if (alreadyProxy && proxyOrigin) {
const po = new URL(proxyOrigin);
raw.protocol = po.protocol;
raw.host = po.host;
base = raw.toString();
}
if (!alreadyProxy && proxyOrigin && isLocalHost) {
const proxy = new URL("/api/onlyoffice/proxy", proxyOrigin || window.location.origin);
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
base = proxy.toString();
alreadyProxy = true;
}
if (!alreadyProxy && raw.searchParams.has("token")) {
const proxy = new URL("/api/onlyoffice/proxy", proxyOrigin || window.location.origin);
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
base = proxy.toString();
alreadyProxy = true;
}
const u = new URL(base);
if (!storageHostOverride || alreadyProxy) return u.toString();
if (/^https?:\/\//i.test(storageHostOverride)) {
const ov = new URL(storageHostOverride);
u.protocol = ov.protocol;
u.host = ov.host;
return u.toString();
}
u.hostname = storageHostOverride;
return u.toString();
} catch {
return base;
}
}
export function buildOnlyOfficeCallbackUrl(input: {
callbackOrigin: string;
proxyOrigin: string;
windowOrigin: string;
assetId: string;
userId: string;
}): string {
const base = input.callbackOrigin || input.proxyOrigin || input.windowOrigin;
const callback = new URL("/api/onlyoffice/callback", base);
if (input.assetId) callback.searchParams.set("assetId", input.assetId);
if (input.userId) callback.searchParams.set("userId", input.userId);
return callback.toString();
}
export function buildOnlyOfficeForcesaveUrl(input: {
windowOrigin: string;
assetId: string;
key: string;
}): string {
const url = new URL("/api/onlyoffice/forcesave", input.windowOrigin);
url.searchParams.set("assetId", input.assetId);
url.searchParams.set("key", input.key);
return url.toString();
}
export function buildOnlyOfficeOpenFileId(input: {
assetId: string;
docKey: string;
resolvedFileUrl: string;
fileName: string;
}): string {
return input.assetId || `onlyoffice_${input.docKey || hashOnlyOfficeKey(`${input.resolvedFileUrl}-${input.fileName}`)}`;
}
@@ -0,0 +1,237 @@
import type { MnoteRuntimeConfig } from "@/lib/runtime-config";
import {
buildDocumentBridgeContextWithActor,
type BridgeContext,
type BridgeTarget,
} from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
type OnlyOfficeHeader = {
name: string;
value: string;
};
type OnlyOfficeServiceActorInput = {
actorId?: string;
actorType?: string;
sessionId?: string | null;
workspaceId?: string | null;
sourceChannel: string;
sourceClient: string;
idempotencyKey?: string | null;
};
export type OnlyOfficeSignResult = {
token: string | null;
documentToken: string | null;
editorConfigToken: string | null;
};
export type OnlyOfficeProxyPreparationResult = {
targetUrl: string;
forwardHeaders: OnlyOfficeHeader[];
};
export type OnlyOfficeCallbackPreparationResult = {
shouldWrite: boolean;
downloadUrl: string | null;
idempotencyKey: string | null;
locator: {
assetId: string;
workspaceId: string | null;
documentId: string | null;
};
session: {
sessionId: string;
assetId: string;
workspaceId: string | null;
documentId: string | null;
userId: string | null;
};
};
export type OnlyOfficeForcesavePreparationResult = {
requests: Array<{
via: string;
url: string;
method: string;
headers: OnlyOfficeHeader[];
bodyJson: string;
}>;
};
function buildOnlyOfficeServiceContext(request: Request, input: OnlyOfficeServiceActorInput): BridgeContext {
return buildDocumentBridgeContextWithActor({
request,
workspaceId: input.workspaceId ?? null,
actor: {
actorType: input.actorType ?? "service",
actorId: input.actorId?.trim() || "onlyoffice-service",
sessionId: input.sessionId ?? null,
},
source: {
channel: input.sourceChannel,
client: input.sourceClient,
},
idempotencyKey: input.idempotencyKey ?? null,
});
}
async function runOnlyOfficeTool<TResult>(input: {
request: Request;
toolName:
| "onlyoffice_session_resolve"
| "onlyoffice_sign"
| "onlyoffice_prepare_proxy"
| "onlyoffice_prepare_callback"
| "onlyoffice_prepare_forcesave";
args?: Record<string, unknown>;
data?: Record<string, unknown>;
target?: BridgeTarget | null;
actor?: Partial<OnlyOfficeServiceActorInput>;
}): Promise<TResult> {
const context = buildOnlyOfficeServiceContext(input.request, {
actorId: input.actor?.actorId,
actorType: input.actor?.actorType,
sessionId: input.actor?.sessionId ?? null,
workspaceId: input.actor?.workspaceId ?? null,
sourceChannel: input.actor?.sourceChannel?.trim() || `onlyoffice:${input.toolName}`,
sourceClient: input.actor?.sourceClient?.trim() || "wolai-frontend",
idempotencyKey: input.actor?.idempotencyKey ?? null,
});
const result = await executeRustBridgeTool<TResult>({
context,
toolName: input.toolName,
invocationKind: "job",
args: input.args ?? {},
data: input.data ?? {},
target: input.target ?? null,
mode: "result",
});
return result.result;
}
export async function signOnlyOfficeConfig(input: {
request: Request;
config: Record<string, unknown>;
secret?: string | null;
}): Promise<OnlyOfficeSignResult> {
return await runOnlyOfficeTool<OnlyOfficeSignResult>({
request: input.request,
toolName: "onlyoffice_sign",
data: {
config: input.config,
secret: input.secret ?? "",
},
actor: {
sourceChannel: "onlyoffice-sign",
sourceClient: "onlyoffice-sign-route",
},
});
}
export async function prepareOnlyOfficeProxyRequest(input: {
request: Request;
encodedUrl: string;
method: "GET" | "HEAD";
range: string | null;
runtimeConfig: MnoteRuntimeConfig;
}): Promise<OnlyOfficeProxyPreparationResult> {
return await runOnlyOfficeTool<OnlyOfficeProxyPreparationResult>({
request: input.request,
toolName: "onlyoffice_prepare_proxy",
data: {
encodedUrl: input.encodedUrl,
method: input.method,
range: input.range,
supabaseUrl: input.runtimeConfig.supabaseUrl ?? "",
supabaseInternalUrl:
input.runtimeConfig.supabaseInternalUrl ?? process.env.SUPABASE_INTERNAL_URL ?? "",
onlyofficeStorageHostOverride: input.runtimeConfig.onlyofficeStorageHostOverride ?? "",
convexOrigin:
process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? "",
supabaseAnonKey: input.runtimeConfig.supabaseAnonKey ?? "",
},
actor: {
sourceChannel: "onlyoffice-proxy",
sourceClient: "onlyoffice-proxy-route",
},
});
}
export async function prepareOnlyOfficeCallback(input: {
request: Request;
assetId: string;
documentId?: string | null;
workspaceId?: string | null;
userId?: string | null;
sessionId?: string | null;
status: number;
url?: string | null;
key?: string | null;
onlyofficeInternalUrl: string;
}): Promise<OnlyOfficeCallbackPreparationResult> {
return await runOnlyOfficeTool<OnlyOfficeCallbackPreparationResult>({
request: input.request,
toolName: "onlyoffice_prepare_callback",
data: {
assetId: input.assetId,
documentId: input.documentId ?? null,
workspaceId: input.workspaceId ?? null,
userId: input.userId ?? null,
sessionId: input.sessionId ?? "onlyoffice-callback",
status: input.status,
url: input.url ?? null,
key: input.key ?? null,
onlyofficeInternalUrl: input.onlyofficeInternalUrl,
},
actor: {
actorId: input.userId?.trim() || "onlyoffice-callback",
sessionId: "onlyoffice-callback",
workspaceId: input.workspaceId ?? null,
idempotencyKey: input.key ?? null,
sourceChannel: "onlyoffice-callback",
sourceClient: "onlyoffice-document-server",
},
target: {
workspaceId: input.workspaceId ?? null,
pageId: input.documentId ?? null,
blockId: input.assetId,
},
});
}
export async function prepareOnlyOfficeForcesave(input: {
request: Request;
assetId: string;
key: string;
onlyofficeInternalUrl: string;
secret?: string | null;
actorId?: string;
workspaceId?: string | null;
documentId?: string | null;
}): Promise<OnlyOfficeForcesavePreparationResult> {
return await runOnlyOfficeTool<OnlyOfficeForcesavePreparationResult>({
request: input.request,
toolName: "onlyoffice_prepare_forcesave",
data: {
assetId: input.assetId,
key: input.key,
onlyofficeInternalUrl: input.onlyofficeInternalUrl,
secret: input.secret ?? "",
},
actor: {
actorId: input.actorId?.trim() || "onlyoffice-forcesave",
sessionId: "onlyoffice-forcesave",
workspaceId: input.workspaceId ?? null,
idempotencyKey: input.key,
sourceChannel: "onlyoffice-forcesave",
sourceClient: "onlyoffice-forcesave-route",
},
target: {
workspaceId: input.workspaceId ?? null,
pageId: input.documentId ?? null,
blockId: input.assetId,
},
});
}