0.4.0 convex及界面修改

This commit is contained in:
liaibo
2026-02-01 08:47:40 +08:00
parent d1f055f51a
commit af92c4b149
636 changed files with 7522 additions and 1815 deletions
+11
View File
@@ -1,5 +1,6 @@
import { ConvexHttpClient } from "convex/browser";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
let cached: ConvexHttpClient | null = null;
@@ -31,6 +32,16 @@ export async function getConvexAuthedHttpClient(): Promise<ConvexHttpClient> {
const token = await convexAuthNextjsToken();
if (!token) {
// 说明:开发用户模式(MNOTE_DEV_AUTH=1)用于迁移/联调阶段的“免登录”体验。
// 此时浏览器侧可能没有 Convex Auth cookies,但服务端仍需要能访问 Convex。
// 若配置了自托管 Admin Key,则允许在开发用户模式下回退到 Admin Auth(仅本地/联调使用)。
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY;
if (adminKey && isDevAuthEnabled()) {
const client = new ConvexHttpClient(url);
(client as any).setAdminAuth(adminKey);
return client;
}
throw new Error("未登录");
}
+11
View File
@@ -3,6 +3,7 @@
*/
export const ASSETS_CHANGED_EVENT = "wolai:assets-changed";
export const DOCUMENTS_CHANGED_EVENT = "wolai:documents-changed";
export const ASSETS_RESTORED_EVENT = "wolai:assets-restored";
type AssetsChangedPayload = {
docId?: string;
@@ -12,6 +13,11 @@ type AssetsChangedPayload = {
mindmapAssetIds?: string[];
};
type AssetsRestoredPayload =
| { docId: string; kind: "media"; assetId: string; asset?: unknown }
| { docId: string; kind: "mindmap"; mindmapId: string }
| { docId: string; kind: "table"; tableId: string };
export function emitAssetsChanged(
docId?: string,
asset?: unknown,
@@ -24,6 +30,11 @@ export function emitAssetsChanged(
window.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail }));
}
export function emitAssetsRestored(payload: AssetsRestoredPayload) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(ASSETS_RESTORED_EVENT, { detail: payload }));
}
export function emitDocumentsChanged(docId?: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(DOCUMENTS_CHANGED_EVENT, { detail: { docId } }));
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
const buildReq = () =>
new Request("https://example.com/api/test", {
headers: {
"x-forwarded-proto": "https",
"x-forwarded-host": "app.example.com",
},
});
const base64UrlEncodeUtf8 = (input: string) => {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
};
describe("maybeProxyForBrowserUrl", () => {
it("对 localhost URL 生成 proxy URL", () => {
const req = buildReq();
const raw = "http://localhost:3210/api/storage/xxx";
const out = maybeProxyForBrowserUrl(req, raw);
const expected = `https://app.example.com/api/onlyoffice/proxy?u=${base64UrlEncodeUtf8(raw)}`;
expect(out).toBe(expected);
});
it("对与 Convex Origin host 匹配的 URL 生成 proxy URL", () => {
process.env.CONVEX_SELF_HOSTED_URL = "http://backend:3210";
const req = buildReq();
const raw = "http://backend:3210/api/storage/yyy";
const out = maybeProxyForBrowserUrl(req, raw);
const expected = `https://app.example.com/api/onlyoffice/proxy?u=${base64UrlEncodeUtf8(raw)}`;
expect(out).toBe(expected);
});
it("对外部 URL 不做处理", () => {
process.env.CONVEX_SELF_HOSTED_URL = "http://backend:3210";
const req = buildReq();
const raw = "https://cdn.example.com/image.png";
const out = maybeProxyForBrowserUrl(req, raw);
expect(out).toBe(raw);
});
it("已经是 /api/onlyoffice/proxy 的 URL 不重复包裹", () => {
const req = buildReq();
const raw = "/api/onlyoffice/proxy?u=abc";
const out = maybeProxyForBrowserUrl(req, raw);
expect(out).toBe(raw);
});
});
@@ -0,0 +1,57 @@
const base64UrlEncodeUtf8 = (input: string) => {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
};
const getRequestOrigin = (request: Request) => {
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
return `${protocol}//${host}`;
};
const isLocalHostname = (hostname: string) =>
hostname === "127.0.0.1" ||
hostname === "localhost" ||
hostname === "host.docker.internal" ||
hostname === "0.0.0.0";
const getConvexOriginHost = () => {
const raw = (process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? "").trim();
if (!raw) return null;
try {
const u = new URL(raw);
return u.hostname;
} catch {
return null;
}
};
/**
* 将“浏览器不可达”的内部回源 URL 包一层 `/api/onlyoffice/proxy?u=...`,避免前端直接请求内网地址。
* 说明:该函数只做代理 URL 的生成;真正的 SSRF 防护在 `/api/onlyoffice/proxy` 内完成。
*/
export const maybeProxyForBrowserUrl = (request: Request, rawUrl: string) => {
const input = String(rawUrl || "").trim();
if (!input) return input;
if (input.startsWith("/api/onlyoffice/proxy")) return input;
try {
const u = new URL(input);
const convexHost = getConvexOriginHost();
const shouldProxy = isLocalHostname(u.hostname) || (convexHost ? u.hostname === convexHost : false);
if (!shouldProxy) return input;
const origin = getRequestOrigin(request);
const proxy = new URL("/api/onlyoffice/proxy", origin);
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
return proxy.toString();
} catch {
return input;
}
};