fix: stabilize dingtalk login and image sizing
This commit is contained in:
parent
510e3dc323
commit
40848ffdb5
@ -374,7 +374,37 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 根据组织名称启动钉钉登录(在 iframe 中打开)
|
const getSafeLoginCallbackUrl = () => {
|
||||||
|
if (typeof window === "undefined") return "/";
|
||||||
|
|
||||||
|
const callbackUrl = new URLSearchParams(window.location.search).get(
|
||||||
|
"callbackUrl",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
callbackUrl &&
|
||||||
|
callbackUrl.startsWith("/") &&
|
||||||
|
!callbackUrl.startsWith("//")
|
||||||
|
) {
|
||||||
|
return callbackUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDingTalkLoginUrl = (orgName?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (orgName) params.set("org", orgName);
|
||||||
|
|
||||||
|
const callbackUrl = getSafeLoginCallbackUrl();
|
||||||
|
if (callbackUrl !== "/") {
|
||||||
|
params.set("callbackUrl", callbackUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return `/api/auth/dingtalk/login${query ? `?${query}` : ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据组织名称启动钉钉登录。钉钉授权页禁止第三方 iframe 嵌入,必须使用顶层跳转。
|
||||||
const startDingTalkLoginForOrg = (orgName: string) => {
|
const startDingTalkLoginForOrg = (orgName: string) => {
|
||||||
// 清除 URL 中的 token 参数,避免干扰
|
// 清除 URL 中的 token 参数,避免干扰
|
||||||
if (
|
if (
|
||||||
@ -385,14 +415,12 @@ export default function Page() {
|
|||||||
window.history.replaceState({}, "", newUrl);
|
window.history.replaceState({}, "", newUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
|
const loginUrl = buildDingTalkLoginUrl(orgName);
|
||||||
orgName,
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
console.log("[DingTalk] Opening org login:", orgName, loginUrl);
|
console.log("[DingTalk] Redirecting org login:", orgName, loginUrl);
|
||||||
setDingTalkIframeSrc(loginUrl);
|
|
||||||
setShowDingTalkModal(true);
|
setShowDingTalkModal(true);
|
||||||
setShowOrgMenu(false);
|
setShowOrgMenu(false);
|
||||||
|
window.location.assign(loginUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 钉钉登录 - 支持多组织
|
// 钉钉登录 - 支持多组织
|
||||||
@ -409,7 +437,6 @@ export default function Page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容:直接使用旧的方式在 iframe 中打开
|
|
||||||
if (
|
if (
|
||||||
typeof window === "undefined" ||
|
typeof window === "undefined" ||
|
||||||
typeof window.location === "undefined"
|
typeof window.location === "undefined"
|
||||||
@ -424,27 +451,9 @@ export default function Page() {
|
|||||||
window.history.replaceState({}, "", newUrl);
|
window.history.replaceState({}, "", newUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const param = {
|
const loginUrl = buildDingTalkLoginUrl();
|
||||||
redirect_uri: encodeURIComponent(
|
console.log("[DingTalk] Redirecting OAuth login:", loginUrl);
|
||||||
window.location.origin + "/dingtalk-callback.html",
|
window.location.assign(loginUrl);
|
||||||
),
|
|
||||||
client_id: dingTalkAppKey,
|
|
||||||
scope: "openid",
|
|
||||||
response_type: "code",
|
|
||||||
state: "default",
|
|
||||||
prompt: "consent",
|
|
||||||
};
|
|
||||||
|
|
||||||
const redirectUrl =
|
|
||||||
"https://login.dingtalk.com/oauth2/auth?" +
|
|
||||||
Object.keys(param)
|
|
||||||
.map((key) => key + "=" + (param as any)[key])
|
|
||||||
.join("&");
|
|
||||||
|
|
||||||
console.log("[DingTalk] Opening OAuth URL (legacy):", redirectUrl);
|
|
||||||
|
|
||||||
setDingTalkIframeSrc(redirectUrl);
|
|
||||||
setShowDingTalkModal(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -633,17 +642,7 @@ export default function Page() {
|
|||||||
<button
|
<button
|
||||||
key={org}
|
key={org}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => startDingTalkLoginForOrg(org)}
|
||||||
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
|
|
||||||
org,
|
|
||||||
)}`;
|
|
||||||
console.log(
|
|
||||||
"[DingTalk] Selected organization:",
|
|
||||||
org,
|
|
||||||
loginUrl,
|
|
||||||
);
|
|
||||||
setDingTalkIframeSrc(loginUrl);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center justify-between rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
className="flex w-full items-center justify-between rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
>
|
>
|
||||||
<span>{org}</span>
|
<span>{org}</span>
|
||||||
@ -651,7 +650,7 @@ export default function Page() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
选择后将在下方加载钉钉登录页面。
|
选择后将跳转到钉钉完成授权。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -6,10 +6,52 @@ import { generateUUID } from "@/lib/utils";
|
|||||||
import { signIn } from "@/app/(auth)/auth";
|
import { signIn } from "@/app/(auth)/auth";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
const normalizeCallbackUrl = (value: unknown) => {
|
||||||
|
if (typeof value !== "string" || !value) return "/";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value, "https://local.invalid");
|
||||||
|
if (parsed.origin !== "https://local.invalid") return "/";
|
||||||
|
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
|
||||||
|
} catch {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseDingTalkState = (state: string | null) => {
|
||||||
|
if (!state) {
|
||||||
|
return { org: undefined as string | undefined, callbackUrl: "/" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.startsWith("v1.")) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(state.slice(3), "base64url").toString("utf8"),
|
||||||
|
) as { org?: unknown; callbackUrl?: unknown };
|
||||||
|
|
||||||
|
return {
|
||||||
|
org:
|
||||||
|
typeof payload.org === "string" && payload.org !== "default"
|
||||||
|
? payload.org
|
||||||
|
: undefined,
|
||||||
|
callbackUrl: normalizeCallbackUrl(payload.callbackUrl),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[DingTalk Callback] Failed to parse state payload:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
org: state !== "default" ? state : undefined,
|
||||||
|
callbackUrl: "/",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const code = searchParams.get("code");
|
const code = searchParams.get("code");
|
||||||
const state = searchParams.get("state");
|
const state = searchParams.get("state");
|
||||||
|
const dingTalkState = parseDingTalkState(state);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"[DingTalk Callback] Received code:",
|
"[DingTalk Callback] Received code:",
|
||||||
@ -31,8 +73,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const organizations = settings.dingTalkOrganizations ?? [];
|
const organizations = settings.dingTalkOrganizations ?? [];
|
||||||
const selectedOrg = state
|
const selectedOrg = dingTalkState.org
|
||||||
? organizations.find((org) => org.name === state)
|
? organizations.find((org) => org.name === dingTalkState.org)
|
||||||
: organizations[0];
|
: organizations[0];
|
||||||
|
|
||||||
const appKey = selectedOrg?.appKey ?? settings.dingTalkAppKey;
|
const appKey = selectedOrg?.appKey ?? settings.dingTalkAppKey;
|
||||||
@ -290,6 +332,7 @@ export async function GET(request: NextRequest) {
|
|||||||
departments: userInfo.departmentNames || [],
|
departments: userInfo.departmentNames || [],
|
||||||
},
|
},
|
||||||
token,
|
token,
|
||||||
|
callbackUrl: dingTalkState.callbackUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,6 +1,24 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getAuthSettings } from "@/lib/db/queries";
|
import { getAuthSettings } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
const normalizeCallbackUrl = (value: string | null) => {
|
||||||
|
if (!value) return "/";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value, "https://local.invalid");
|
||||||
|
if (parsed.origin !== "https://local.invalid") return "/";
|
||||||
|
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
|
||||||
|
} catch {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodeState = (payload: { org: string; callbackUrl: string }) => {
|
||||||
|
return `v1.${Buffer.from(JSON.stringify(payload), "utf8").toString(
|
||||||
|
"base64url",
|
||||||
|
)}`;
|
||||||
|
};
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const settings = await getAuthSettings();
|
const settings = await getAuthSettings();
|
||||||
|
|
||||||
@ -34,8 +52,11 @@ export async function GET(request: Request) {
|
|||||||
const baseUrl = process.env.AUTH_URL || requestUrl.origin;
|
const baseUrl = process.env.AUTH_URL || requestUrl.origin;
|
||||||
const redirectUri = `${baseUrl}/dingtalk-callback.html`;
|
const redirectUri = `${baseUrl}/dingtalk-callback.html`;
|
||||||
|
|
||||||
// 使用组织名称作为 state,便于回调时选择对应的配置
|
// state 中保存组织和登录后的回跳地址,回调接口仍兼容旧的纯组织名 state。
|
||||||
const state = orgName || selectedOrg?.name || "default";
|
const state = encodeState({
|
||||||
|
org: orgName || selectedOrg?.name || "default",
|
||||||
|
callbackUrl: normalizeCallbackUrl(urlObj.searchParams.get("callbackUrl")),
|
||||||
|
});
|
||||||
|
|
||||||
// 钉钉 OAuth 2.0 授权地址
|
// 钉钉 OAuth 2.0 授权地址
|
||||||
const url =
|
const url =
|
||||||
|
|||||||
@ -26,6 +26,7 @@ interface UploadedImage {
|
|||||||
const DEFAULT_IMAGE_SIZE = "2K";
|
const DEFAULT_IMAGE_SIZE = "2K";
|
||||||
const DEFAULT_TIMEOUT_MS = 300000;
|
const DEFAULT_TIMEOUT_MS = 300000;
|
||||||
const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400;
|
const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400;
|
||||||
|
const OPENAI_IMAGE_DIMENSION_MULTIPLE = 16;
|
||||||
const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i;
|
const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i;
|
||||||
const KONTEXT_MODEL_REGEX = /kontext/i;
|
const KONTEXT_MODEL_REGEX = /kontext/i;
|
||||||
const SUPPORTED_ASPECT_RATIOS = [
|
const SUPPORTED_ASPECT_RATIOS = [
|
||||||
@ -83,16 +84,32 @@ function normalizeAspectRatio(value?: string) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAspectRatioValue(ratio: string) {
|
function parseAspectRatioPair(ratio: string) {
|
||||||
const [width, height] = ratio.split(":").map((item) => Number(item));
|
const [width, height] = ratio.split(":").map((item) => Number(item));
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
return 1;
|
return null;
|
||||||
}
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAspectRatioValue(ratio: string) {
|
||||||
|
const pair = parseAspectRatioPair(ratio);
|
||||||
|
if (!pair) return 1;
|
||||||
|
const { width, height } = pair;
|
||||||
return width / height;
|
return width / height;
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundEven(value: number) {
|
function ceilToMultiple(value: number, multiple: number) {
|
||||||
return Math.max(2, Math.round(value / 2) * 2);
|
return Math.max(multiple, Math.ceil(value / multiple) * multiple);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferAspectRatioFromPrompt(prompt: string) {
|
||||||
|
const normalized = String(prompt || "").replace(/:/g, ":");
|
||||||
|
const match = normalized.match(/(\d{1,2})\s*(?::|比)\s*(\d{1,2})/);
|
||||||
|
if (!match) return undefined;
|
||||||
|
|
||||||
|
const ratio = `${Number(match[1])}:${Number(match[2])}`;
|
||||||
|
return (SUPPORTED_ASPECT_RATIOS as readonly string[]).includes(ratio) ? ratio : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferClosestAspectRatio(aspectValue: number) {
|
function inferClosestAspectRatio(aspectValue: number) {
|
||||||
@ -151,29 +168,15 @@ function resolveOpenAISize(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
|
|||||||
"4K": 2048,
|
"4K": 2048,
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeRatio = parseAspectRatioValue(aspectRatio);
|
const ratioPair = parseAspectRatioPair(aspectRatio) || { width: 1, height: 1 };
|
||||||
const base = baseMap[imageSize] || 1536;
|
const base = baseMap[imageSize] || 1536;
|
||||||
const targetPixels = Math.max(base * base, OPENAI_IMAGE_MIN_TOTAL_PIXELS);
|
const targetPixels = Math.max(base * base, OPENAI_IMAGE_MIN_TOTAL_PIXELS);
|
||||||
|
const scale = ceilToMultiple(
|
||||||
let width: number;
|
Math.sqrt(targetPixels / (ratioPair.width * ratioPair.height)),
|
||||||
let height: number;
|
OPENAI_IMAGE_DIMENSION_MULTIPLE,
|
||||||
if (safeRatio >= 1) {
|
);
|
||||||
width = roundEven(Math.sqrt(targetPixels * safeRatio));
|
const width = ratioPair.width * scale;
|
||||||
height = roundEven(width / safeRatio);
|
const height = ratioPair.height * scale;
|
||||||
} else {
|
|
||||||
height = roundEven(Math.sqrt(targetPixels / safeRatio));
|
|
||||||
width = roundEven(height * safeRatio);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (width * height < targetPixels) {
|
|
||||||
if (safeRatio >= 1) {
|
|
||||||
width += 2;
|
|
||||||
height = roundEven(width / safeRatio);
|
|
||||||
} else {
|
|
||||||
height += 2;
|
|
||||||
width = roundEven(height * safeRatio);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${width}x${height}`;
|
return `${width}x${height}`;
|
||||||
}
|
}
|
||||||
@ -530,9 +533,10 @@ export function createOpenAIImage({ apiKey, baseURL }: OpenAIImageConfig) {
|
|||||||
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
||||||
const activeModelId = options.overrideModelId || modelId;
|
const activeModelId = options.overrideModelId || modelId;
|
||||||
const normalizedAspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
|
const normalizedAspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
|
||||||
|
const promptAspectRatio = inferAspectRatioFromPrompt(prompt);
|
||||||
const aspectRatio =
|
const aspectRatio =
|
||||||
normalizedAspectRatio === "auto"
|
normalizedAspectRatio === "auto"
|
||||||
? await inferAspectRatioFromImageUrl(imageUrls[0] || "")
|
? promptAspectRatio || (await inferAspectRatioFromImageUrl(imageUrls[0] || ""))
|
||||||
: normalizedAspectRatio;
|
: normalizedAspectRatio;
|
||||||
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
|
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
|
||||||
const size =
|
const size =
|
||||||
@ -546,6 +550,7 @@ export function createOpenAIImage({ apiKey, baseURL }: OpenAIImageConfig) {
|
|||||||
model: activeModelId,
|
model: activeModelId,
|
||||||
size,
|
size,
|
||||||
aspectRatio: aspectRatio || "auto",
|
aspectRatio: aspectRatio || "auto",
|
||||||
|
promptAspectRatio: promptAspectRatio || undefined,
|
||||||
imageSize,
|
imageSize,
|
||||||
useBltcyFluxCompatParams: shouldUseBltcyFluxCompatParams(baseURL, activeModelId),
|
useBltcyFluxCompatParams: shouldUseBltcyFluxCompatParams(baseURL, activeModelId),
|
||||||
imageCount: imageUrls.length,
|
imageCount: imageUrls.length,
|
||||||
|
|||||||
@ -127,7 +127,7 @@
|
|||||||
console.log('[DingTalk Callback] Got token:', data.token.substring(0, 10) + '...');
|
console.log('[DingTalk Callback] Got token:', data.token.substring(0, 10) + '...');
|
||||||
|
|
||||||
// 直接在这里完成 Auth.js 登录
|
// 直接在这里完成 Auth.js 登录
|
||||||
completeLogin(data.token);
|
completeLogin(data.token, normalizeCallbackUrl(data.callbackUrl));
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || data.message || '登录失败');
|
throw new Error(data.error || data.message || '登录失败');
|
||||||
}
|
}
|
||||||
@ -137,7 +137,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeLogin(token) {
|
function normalizeCallbackUrl(callbackUrl) {
|
||||||
|
if (typeof callbackUrl !== 'string' || !callbackUrl) return '/';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(callbackUrl, window.location.origin);
|
||||||
|
if (parsed.origin !== window.location.origin) return '/';
|
||||||
|
return parsed.pathname + parsed.search + parsed.hash;
|
||||||
|
} catch (error) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeLogin(token, callbackUrl) {
|
||||||
try {
|
try {
|
||||||
statusText.textContent = '正在登录系统...';
|
statusText.textContent = '正在登录系统...';
|
||||||
|
|
||||||
@ -157,7 +168,7 @@
|
|||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
csrfToken: csrfToken,
|
csrfToken: csrfToken,
|
||||||
token: token,
|
token: token,
|
||||||
callbackUrl: '/',
|
callbackUrl: callbackUrl,
|
||||||
}),
|
}),
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
@ -183,14 +194,14 @@
|
|||||||
// 使用 window.top.location 确保跳转整个页面(即使在 iframe 中)
|
// 使用 window.top.location 确保跳转整个页面(即使在 iframe 中)
|
||||||
try {
|
try {
|
||||||
if (window.top && window.top.location) {
|
if (window.top && window.top.location) {
|
||||||
window.top.location.replace('/');
|
window.top.location.replace(callbackUrl);
|
||||||
} else {
|
} else {
|
||||||
window.location.replace('/');
|
window.location.replace(callbackUrl);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 如果跨域阻止,使用当前窗口跳转
|
// 如果跨域阻止,使用当前窗口跳转
|
||||||
console.warn('[DingTalk Callback] Cross-origin blocked, using current window');
|
console.warn('[DingTalk Callback] Cross-origin blocked, using current window');
|
||||||
window.location.replace('/');
|
window.location.replace(callbackUrl);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('[DingTalk Callback] Session not created, retrying...');
|
console.error('[DingTalk Callback] Session not created, retrying...');
|
||||||
@ -201,7 +212,7 @@
|
|||||||
});
|
});
|
||||||
const retrySession = await retrySessionRes.json();
|
const retrySession = await retrySessionRes.json();
|
||||||
if (retrySession && retrySession.user) {
|
if (retrySession && retrySession.user) {
|
||||||
window.location.replace('/');
|
window.location.replace(callbackUrl);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('登录失败:无法创建会话,请刷新页面重试');
|
throw new Error('登录失败:无法创建会话,请刷新页面重试');
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user