0.2 在线版本打通
This commit is contained in:
@@ -2,7 +2,7 @@ import "server-only";
|
||||
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { preferredBaseDir, legacyBaseDir } from "@/lib/mindmap-files";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
export function resolveMindmapFileName(mindmapId: string) {
|
||||
if (mindmapId === "legacy" || mindmapId.startsWith("legacy-")) {
|
||||
@@ -38,6 +38,8 @@ export type ReadMindmapResult =
|
||||
| { ok: false; data: null; source: null };
|
||||
|
||||
export async function readMindmapLocal(docId: string, mindmapId: string): Promise<ReadMindmapResult> {
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
const preferredFolder = path.join(preferredBaseDir, docId);
|
||||
const preferredFile = path.join(preferredFolder, resolveMindmapFileName(mindmapId));
|
||||
const preferredLegacy = path.join(preferredFolder, "mindmap.json");
|
||||
@@ -61,11 +63,10 @@ export async function writeMindmapLocal(
|
||||
data: unknown,
|
||||
docTitle: string,
|
||||
) {
|
||||
const folder = path.join(preferredBaseDir, docId);
|
||||
const folder = path.join(getDocumentsBaseDir(), docId);
|
||||
const file = path.join(folder, resolveMindmapFileName(mindmapId));
|
||||
await ensureDir(folder);
|
||||
await ensureIndexFile(folder, docTitle || "无标题");
|
||||
await fs.writeFile(file, JSON.stringify(data ?? { data: { text: "中心主题" }, children: [] }, null, 2), "utf8");
|
||||
return { folder, file };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
export type MnoteRuntimeConfig = {
|
||||
supabaseUrl?: string;
|
||||
/**
|
||||
* 服务端/本机回源用的 Supabase 地址(通常是 HTTP),用于避免 FRP/自签证书导致 Node 侧 TLS 校验失败。
|
||||
* 说明:该字段会出现在 public/mnote-env.json 中,但它主要给服务端读取使用。
|
||||
*/
|
||||
supabaseInternalUrl?: string;
|
||||
supabaseAnonKey?: string;
|
||||
backendUrl?: string;
|
||||
onlyofficeBaseUrl?: string;
|
||||
onlyofficeBaseUrlWeb?: string;
|
||||
onlyofficeBaseUrlDesktop?: string;
|
||||
onlyofficeStorageHostOverride?: string;
|
||||
onlyofficeStorageHostOverrideWeb?: string;
|
||||
onlyofficeStorageHostOverrideDesktop?: string;
|
||||
cloudflareAppOrigin?: string;
|
||||
onlyofficeProxyOriginWeb?: string;
|
||||
onlyofficeCallbackOriginWeb?: string;
|
||||
onlyofficeCallbackOriginDesktop?: string;
|
||||
/**
|
||||
* 是否为桌面端(Electron)运行。
|
||||
*/
|
||||
isDesktop?: boolean;
|
||||
/**
|
||||
* ONLYOFFICE 文档服务器拉取 document.url 时,如需使用 /api/onlyoffice/proxy
|
||||
* 规避 signedUrl 的 token 参数冲突,可把代理地址指向一个“文档服务器可访问”的公网 Origin。
|
||||
*
|
||||
* - 桌面端本地运行(window.location.origin):通常可不配置
|
||||
* - 通过 Cloudflare Tunnel 使用远端 ONLYOFFICE:建议配置为 https://app.<你的域名>
|
||||
*/
|
||||
onlyofficeProxyOrigin?: string;
|
||||
onlyofficeCallbackOrigin?: string;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MNOTE_RUNTIME_CONFIG__?: MnoteRuntimeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const readFromEnv = (): MnoteRuntimeConfig => ({
|
||||
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
|
||||
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
|
||||
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
|
||||
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
|
||||
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
|
||||
onlyofficeCallbackOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_CALLBACK_ORIGIN,
|
||||
cloudflareAppOrigin: process.env.NEXT_PUBLIC_CLOUDFLARE_APP_ORIGIN,
|
||||
});
|
||||
|
||||
const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
|
||||
if (typeof window !== "undefined") return {};
|
||||
try {
|
||||
// 说明:桌面端与网页端共用 public/mnote-env.json 作为“公共环境文件”。
|
||||
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
|
||||
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require("fs") as typeof import("fs");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require("path") as typeof import("path");
|
||||
|
||||
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`,
|
||||
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
|
||||
// 这里向上查找多级目录,确保能读取到真正的 public/mnote-env.json。
|
||||
const candidates: string[] = [];
|
||||
let dir = process.cwd();
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
candidates.push(path.join(dir, "public", "mnote-env.json"));
|
||||
const next = path.dirname(dir);
|
||||
if (next === dir) break;
|
||||
dir = next;
|
||||
}
|
||||
const existing = candidates.filter((p) => fs.existsSync(p));
|
||||
if (existing.length === 0) return {};
|
||||
|
||||
// 说明:standalone 产物可能包含 `.next/standalone/public/mnote-env.json`,但它通常是构建时拷贝,
|
||||
// 用户更希望修改“项目目录下”的 public/mnote-env.json 即可生效。
|
||||
// 因此这里优先选择不在 `.next` 目录下的配置文件。
|
||||
const isInNextDir = (p: string) => p.split(path.sep).includes(".next");
|
||||
const filePath = existing.find((p) => !isInNextDir(p)) ?? existing[0];
|
||||
|
||||
const raw = fs.readFileSync(filePath, { encoding: "utf8" });
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
return parsed as Partial<MnoteRuntimeConfig>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig => {
|
||||
const isDesktop =
|
||||
cfg.isDesktop ??
|
||||
(typeof window === "undefined" ? process.env.MNOTE_DESKTOP === "1" : false);
|
||||
|
||||
// 说明:`onlyofficeBaseUrl` / `onlyofficeStorageHostOverride` 属于“通用默认值”。
|
||||
// 但我们在 `public/mnote-env.json` 里同时提供了 Web/Desktop 两套配置,
|
||||
// 因此这里应当优先选择与平台匹配的字段,避免被环境变量中的默认值覆盖。
|
||||
//
|
||||
// 典型场景:开发机环境变量仍是 `http://localhost:8081`,但网页端需要走
|
||||
// `https://onlyoffice.<域名>`。若不调整优先级,远程浏览器会尝试访问它自己
|
||||
// 的 localhost,从而导致 docx 打不开。
|
||||
const onlyofficeBaseUrl = isDesktop
|
||||
? cfg.onlyofficeBaseUrlDesktop ||
|
||||
cfg.onlyofficeBaseUrl ||
|
||||
cfg.onlyofficeBaseUrlWeb ||
|
||||
""
|
||||
: cfg.onlyofficeBaseUrlWeb ||
|
||||
cfg.onlyofficeBaseUrl ||
|
||||
cfg.onlyofficeBaseUrlDesktop ||
|
||||
"";
|
||||
|
||||
// 说明:这里需要用 `??` 而不是 `||`,允许通过配置显式传入空字符串来“关闭 override”。
|
||||
// 否则 Web 端配置为 "" 时,会被环境变量里的默认值(例如 host.docker.internal)误覆盖,
|
||||
// 进而导致 ONLYOFFICE 文档服务器无法访问真实的存储地址。
|
||||
const onlyofficeStorageHostOverride = isDesktop
|
||||
? (cfg.onlyofficeStorageHostOverrideDesktop ??
|
||||
cfg.onlyofficeStorageHostOverride ??
|
||||
cfg.onlyofficeStorageHostOverrideWeb ??
|
||||
"")
|
||||
: (cfg.onlyofficeStorageHostOverrideWeb ??
|
||||
cfg.onlyofficeStorageHostOverride ??
|
||||
cfg.onlyofficeStorageHostOverrideDesktop ??
|
||||
"");
|
||||
|
||||
// 说明:`onlyofficeProxyOrigin` 也属于“通用默认值”。在 Web 端需要优先使用
|
||||
// onlyofficeProxyOriginWeb,避免被旧的通用值(例如 Cloudflare 域名)覆盖,
|
||||
// 否则会导致 ONLYOFFICE 文档服务器回源到错误的公网入口。
|
||||
const onlyofficeProxyOrigin = isDesktop
|
||||
? cfg.onlyofficeProxyOrigin || ""
|
||||
: cfg.onlyofficeProxyOriginWeb ||
|
||||
cfg.onlyofficeProxyOrigin ||
|
||||
"";
|
||||
|
||||
// 说明:ONLYOFFICE 回调(保存)必须是“文档服务器可访问”的地址。
|
||||
// Web 端优先用 onlyofficeCallbackOriginWeb(通常是 http://host.docker.internal:3000)。
|
||||
const onlyofficeCallbackOrigin = isDesktop
|
||||
? cfg.onlyofficeCallbackOriginDesktop ||
|
||||
cfg.onlyofficeCallbackOrigin ||
|
||||
cfg.onlyofficeCallbackOriginWeb ||
|
||||
""
|
||||
: cfg.onlyofficeCallbackOriginWeb ||
|
||||
cfg.onlyofficeCallbackOrigin ||
|
||||
cfg.onlyofficeCallbackOriginDesktop ||
|
||||
"";
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
isDesktop,
|
||||
onlyofficeBaseUrl,
|
||||
onlyofficeStorageHostOverride,
|
||||
onlyofficeProxyOrigin,
|
||||
onlyofficeCallbackOrigin,
|
||||
};
|
||||
};
|
||||
|
||||
export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
|
||||
if (typeof window !== "undefined") {
|
||||
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
|
||||
}
|
||||
const isDesktop = process.env.MNOTE_DESKTOP === "1";
|
||||
// 说明:桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。
|
||||
// Web 端开发时则应优先使用环境变量(例如本机 http://127.0.0.1:18000),避免被
|
||||
// public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。
|
||||
const merged: MnoteRuntimeConfig = isDesktop
|
||||
? {
|
||||
...readFromEnv(),
|
||||
...readFromPublicJson(),
|
||||
isDesktop,
|
||||
}
|
||||
: {
|
||||
...readFromPublicJson(),
|
||||
...readFromEnv(),
|
||||
isDesktop,
|
||||
};
|
||||
return normalizeRuntimeConfig(merged);
|
||||
};
|
||||
@@ -6,7 +6,18 @@ type RequestCookies = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
const decodeValue = (value?: string) => {
|
||||
if (!value) return value;
|
||||
return value.startsWith("base64-") ? Buffer.from(value.slice(7), "base64").toString("utf8") : value;
|
||||
let v = value;
|
||||
// 说明:部分环境下 cookies() 读取到的值仍是 URL 编码(例如 %5B%22...%22%5D),
|
||||
// Supabase Auth Helpers 期望拿到可直接 JSON.parse 的字符串,因此这里做一次解码。
|
||||
// 若不是合法的 URL 编码字符串,decodeURIComponent 会抛错,我们直接兜底返回原值。
|
||||
if (v.includes("%")) {
|
||||
try {
|
||||
v = decodeURIComponent(v);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return v.startsWith("base64-") ? Buffer.from(v.slice(7), "base64").toString("utf8") : v;
|
||||
};
|
||||
|
||||
const encodeValue = (value: string) => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import "server-only";
|
||||
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* 本地文件路径策略(服务端专用)
|
||||
*
|
||||
* 目标:
|
||||
* - 开发/传统模式:继续使用 `process.cwd()/public/*`(便于本地调试与静态访问)。
|
||||
* - 桌面端(Electron):通过注入 `MNOTE_DATA_DIR=<安装目录>\\data`,把可变数据
|
||||
* 放到安装目录下(而不是 resources/app.asar),避免权限与覆盖问题。
|
||||
*/
|
||||
|
||||
function cleanEnvValue(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const v = value.trim();
|
||||
return v.length > 0 ? v : null;
|
||||
}
|
||||
|
||||
export function getDocumentsBaseDir(): string {
|
||||
const dataDir = cleanEnvValue(process.env.MNOTE_DATA_DIR);
|
||||
if (dataDir) return path.join(dataDir, "documents");
|
||||
return path.join(process.cwd(), "public", "documents");
|
||||
}
|
||||
|
||||
export function getLegacyMindmapsBaseDir(): string {
|
||||
const dataDir = cleanEnvValue(process.env.MNOTE_DATA_DIR);
|
||||
if (dataDir) return path.join(dataDir, "mindmaps");
|
||||
return path.join(process.cwd(), "public", "mindmaps");
|
||||
}
|
||||
|
||||
+15
-8
@@ -1,10 +1,9 @@
|
||||
import "server-only";
|
||||
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
const tryAccess = async (file: string) => {
|
||||
try {
|
||||
@@ -17,6 +16,8 @@ const tryAccess = async (file: string) => {
|
||||
|
||||
export async function detectLocalMindmapDocs(docIds: string[]): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
for (const id of docIds) {
|
||||
const folder = path.join(preferredBaseDir, id);
|
||||
const preferredLegacy = path.join(folder, "mindmap.json");
|
||||
@@ -39,8 +40,6 @@ export async function detectLocalMindmapDocs(docIds: string[]): Promise<string[]
|
||||
return results;
|
||||
}
|
||||
|
||||
export { preferredBaseDir, legacyBaseDir };
|
||||
|
||||
export type LocalMindmapFile = {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
@@ -50,6 +49,8 @@ export type LocalMindmapFile = {
|
||||
|
||||
export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMindmapFile[]> {
|
||||
const results: LocalMindmapFile[] = [];
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
for (const id of docIds) {
|
||||
const folder = path.join(preferredBaseDir, id);
|
||||
try {
|
||||
@@ -91,8 +92,9 @@ export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMi
|
||||
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
|
||||
const root = (() => {
|
||||
if (!input || typeof input !== "object") return input;
|
||||
const record = input as Record<string, unknown>;
|
||||
return "root" in record ? record.root : input;
|
||||
const record = input;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (record && typeof record === "object" && "root" in (record as any)) ? (record as any).root : input;
|
||||
})();
|
||||
|
||||
const ids: string[] = [];
|
||||
@@ -163,10 +165,12 @@ export async function detectLocalMindmapImageAssetIdsByMindmapId(
|
||||
files: LocalMindmapFile[],
|
||||
): Promise<Record<string, string[]>> {
|
||||
const mapping: Record<string, string[]> = {};
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
for (const item of files) {
|
||||
const baseDir = item.source === "legacy" ? legacyBaseDir : preferredBaseDir;
|
||||
const filePath = path.join(baseDir, item.documentId, item.fileName);
|
||||
const filePath = path.join(baseDir, item.documentId, item.fileName);
|
||||
const data = await tryReadJsonFile<unknown>(filePath);
|
||||
if (!data) continue;
|
||||
const ids = extractMindmapImageAssetIdsFromData(data);
|
||||
@@ -211,6 +215,8 @@ export async function detectLocalTrashedMindmapAssets(
|
||||
docIds: string[],
|
||||
): Promise<MediaAsset[]> {
|
||||
const results: MediaAsset[] = [];
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
for (const docId of docIds) {
|
||||
const preferredFolder = path.join(preferredBaseDir, docId);
|
||||
@@ -250,3 +256,4 @@ export async function detectLocalTrashedMindmapAssets(
|
||||
results.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""));
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
const runtime = getMnoteRuntimeConfig();
|
||||
|
||||
// 说明:服务端优先走内网/本机(HTTP),避免 FRP/证书环境导致 Node fetch TLS 校验失败。
|
||||
// 若未配置 SUPABASE_INTERNAL_URL,再回退到公网 supabaseUrl。
|
||||
const supabaseAdminUrl =
|
||||
runtime.supabaseInternalUrl ||
|
||||
process.env.SUPABASE_INTERNAL_URL ||
|
||||
runtime.supabaseUrl ||
|
||||
process.env.SUPABASE_URL ||
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
"";
|
||||
|
||||
const supabaseAdmin = createClient(
|
||||
process.env.SUPABASE_INTERNAL_URL ?? process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
|
||||
supabaseAdminUrl,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
|
||||
{
|
||||
auth: {
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "@/types/supabase";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
let cachedClient: SupabaseClient<Database> | null = null;
|
||||
let cachedKey = "";
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error("缺少 Supabase 环境变量,请在 .env.local 配置 NEXT_PUBLIC_SUPABASE_URL 与 NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||
export function getSupabaseBrowserClient(): SupabaseClient<Database> {
|
||||
const runtimeConfig = getMnoteRuntimeConfig();
|
||||
const supabaseUrl = runtimeConfig.supabaseUrl;
|
||||
const supabaseAnonKey = runtimeConfig.supabaseAnonKey;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error(
|
||||
"缺少 Supabase 运行期配置:请检查 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY 是否已注入",
|
||||
);
|
||||
}
|
||||
|
||||
const key = `${supabaseUrl}::${supabaseAnonKey}`;
|
||||
if (cachedClient && cachedKey === key) return cachedClient;
|
||||
|
||||
cachedKey = key;
|
||||
cachedClient = createClientComponentClient<Database>({
|
||||
supabaseUrl,
|
||||
supabaseKey: supabaseAnonKey,
|
||||
});
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
export const supabaseBrowser = createClientComponentClient({
|
||||
supabaseUrl,
|
||||
supabaseKey: supabaseAnonKey,
|
||||
});
|
||||
|
||||
@@ -1,36 +1,71 @@
|
||||
import { createServerComponentClient, createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { getDecodedCookies } from "@/lib/server-cookies";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
const getAuthStorageKey = (supabaseUrl: string) => {
|
||||
// 说明:supabase-js 默认用 “项目 ref(hostname 第一个片段)” 作为 storageKey,
|
||||
// 同时 auth-helpers 会把该 storageKey 作为 cookie 名(sb-<ref>-auth-token)。
|
||||
// 我们服务端为了绕过 FRP 自签证书,会把 supabaseUrl 指向内网/本机(例如 127.0.0.1),
|
||||
// 但 cookie 名必须仍然按“公网 supabaseUrl”计算,否则会读不到浏览器写入的 cookie。
|
||||
const hostname = new URL(supabaseUrl).hostname;
|
||||
const ref = hostname.split(".")[0] || hostname;
|
||||
return `sb-${ref}-auth-token`;
|
||||
};
|
||||
|
||||
export const createSupabaseServerClient = async () => {
|
||||
const cookieStore = await getDecodedCookies();
|
||||
const supabaseUrl = process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
const cfg = getMnoteRuntimeConfig();
|
||||
const publicSupabaseUrl = cfg.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
||||
const internalSupabaseUrl =
|
||||
cfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL || publicSupabaseUrl;
|
||||
const supabaseAnonKey =
|
||||
cfg.supabaseAnonKey ?? process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
if (!publicSupabaseUrl || !internalSupabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error(
|
||||
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
|
||||
);
|
||||
}
|
||||
return createServerComponentClient({
|
||||
cookies: () => cookieStore as any,
|
||||
supabaseUrl,
|
||||
supabaseKey: supabaseAnonKey,
|
||||
} as any);
|
||||
|
||||
// 关键:服务端请求尽量走内网/本机(HTTP),避免 FRP Auto HTTPS 的证书导致 Node 侧校验失败。
|
||||
// 但 storageKey/cookie 名称必须与浏览器端一致(使用 publicSupabaseUrl 计算),否则会读不到会话。
|
||||
const storageKey = getAuthStorageKey(publicSupabaseUrl);
|
||||
// 注意:@supabase/auth-helpers-nextjs 的 createServerComponentClient 签名是 (context, options)。
|
||||
// 如果把 supabaseUrl/supabaseKey 放进第一个参数,会被当成 context 字段而忽略,导致仍使用默认
|
||||
// NEXT_PUBLIC_SUPABASE_URL(HTTPS),从而触发 Node 端自签证书报错。
|
||||
return createServerComponentClient(
|
||||
{ cookies: () => cookieStore as any },
|
||||
{
|
||||
supabaseUrl: internalSupabaseUrl,
|
||||
supabaseKey: supabaseAnonKey,
|
||||
// 关键:显式覆盖 storageKey,让 supabase-js 读取 sb-<公网 ref>-auth-token,
|
||||
// 而不是 sb-<127>-auth-token。
|
||||
options: { auth: { storageKey } } as any,
|
||||
} as any,
|
||||
);
|
||||
};
|
||||
|
||||
export const createSupabaseRouteClient = async () => {
|
||||
const cookieStore = await getDecodedCookies();
|
||||
const supabaseUrl = process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
const cfg = getMnoteRuntimeConfig();
|
||||
const publicSupabaseUrl = cfg.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
||||
const internalSupabaseUrl =
|
||||
cfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL || publicSupabaseUrl;
|
||||
const supabaseAnonKey =
|
||||
cfg.supabaseAnonKey ?? process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
if (!publicSupabaseUrl || !internalSupabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error(
|
||||
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
|
||||
);
|
||||
}
|
||||
return createRouteHandlerClient({
|
||||
cookies: () => cookieStore as any,
|
||||
supabaseUrl,
|
||||
supabaseKey: supabaseAnonKey,
|
||||
} as any);
|
||||
const storageKey = getAuthStorageKey(publicSupabaseUrl);
|
||||
return createRouteHandlerClient(
|
||||
{ cookies: () => cookieStore as any },
|
||||
{
|
||||
supabaseUrl: internalSupabaseUrl,
|
||||
supabaseKey: supabaseAnonKey,
|
||||
options: { auth: { storageKey } } as any,
|
||||
} as any,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,20 @@ export const rewriteToPublicOrigin = (rawUrl: string, publicBaseUrl?: string) =>
|
||||
const u = new URL(input);
|
||||
const pub = new URL(publicBaseUrl);
|
||||
|
||||
// 说明:如果是 Supabase 的典型路径(storage/auth/rest/functions/realtime),即使来源域名不同
|
||||
// 也统一改为当前“公网可达”的 Supabase origin,避免历史/旧隧道域名导致不可访问。
|
||||
const isSupabasePath =
|
||||
u.pathname.startsWith("/storage/v1/") ||
|
||||
u.pathname.startsWith("/auth/v1/") ||
|
||||
u.pathname.startsWith("/rest/v1/") ||
|
||||
u.pathname.startsWith("/functions/v1/") ||
|
||||
u.pathname.startsWith("/realtime/v1/");
|
||||
if (isSupabasePath && u.host !== pub.host) {
|
||||
u.protocol = pub.protocol;
|
||||
u.host = pub.host;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
const isLocalHost =
|
||||
u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
|
||||
const isLikelyInternalPort = u.port === "18000";
|
||||
@@ -24,4 +38,3 @@ export const rewriteToPublicOrigin = (rawUrl: string, publicBaseUrl?: string) =>
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user