feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user