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) => {
|
||||
// 清除 URL 中的 token 参数,避免干扰
|
||||
if (
|
||||
@ -385,14 +415,12 @@ export default function Page() {
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
|
||||
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
|
||||
orgName,
|
||||
)}`;
|
||||
const loginUrl = buildDingTalkLoginUrl(orgName);
|
||||
|
||||
console.log("[DingTalk] Opening org login:", orgName, loginUrl);
|
||||
setDingTalkIframeSrc(loginUrl);
|
||||
console.log("[DingTalk] Redirecting org login:", orgName, loginUrl);
|
||||
setShowDingTalkModal(true);
|
||||
setShowOrgMenu(false);
|
||||
window.location.assign(loginUrl);
|
||||
};
|
||||
|
||||
// 钉钉登录 - 支持多组织
|
||||
@ -409,7 +437,6 @@ export default function Page() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 兼容:直接使用旧的方式在 iframe 中打开
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.location === "undefined"
|
||||
@ -424,27 +451,9 @@ export default function Page() {
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
|
||||
const param = {
|
||||
redirect_uri: encodeURIComponent(
|
||||
window.location.origin + "/dingtalk-callback.html",
|
||||
),
|
||||
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);
|
||||
const loginUrl = buildDingTalkLoginUrl();
|
||||
console.log("[DingTalk] Redirecting OAuth login:", loginUrl);
|
||||
window.location.assign(loginUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -633,17 +642,7 @@ export default function Page() {
|
||||
<button
|
||||
key={org}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
|
||||
org,
|
||||
)}`;
|
||||
console.log(
|
||||
"[DingTalk] Selected organization:",
|
||||
org,
|
||||
loginUrl,
|
||||
);
|
||||
setDingTalkIframeSrc(loginUrl);
|
||||
}}
|
||||
onClick={() => startDingTalkLoginForOrg(org)}
|
||||
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>
|
||||
@ -651,7 +650,7 @@ export default function Page() {
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择后将在下方加载钉钉登录页面。
|
||||
选择后将跳转到钉钉完成授权。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -6,10 +6,52 @@ import { generateUUID } from "@/lib/utils";
|
||||
import { signIn } from "@/app/(auth)/auth";
|
||||
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) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const dingTalkState = parseDingTalkState(state);
|
||||
|
||||
console.log(
|
||||
"[DingTalk Callback] Received code:",
|
||||
@ -31,8 +73,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const organizations = settings.dingTalkOrganizations ?? [];
|
||||
const selectedOrg = state
|
||||
? organizations.find((org) => org.name === state)
|
||||
const selectedOrg = dingTalkState.org
|
||||
? organizations.find((org) => org.name === dingTalkState.org)
|
||||
: organizations[0];
|
||||
|
||||
const appKey = selectedOrg?.appKey ?? settings.dingTalkAppKey;
|
||||
@ -290,6 +332,7 @@ export async function GET(request: NextRequest) {
|
||||
departments: userInfo.departmentNames || [],
|
||||
},
|
||||
token,
|
||||
callbackUrl: dingTalkState.callbackUrl,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@ -1,6 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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) {
|
||||
const settings = await getAuthSettings();
|
||||
|
||||
@ -34,8 +52,11 @@ export async function GET(request: Request) {
|
||||
const baseUrl = process.env.AUTH_URL || requestUrl.origin;
|
||||
const redirectUri = `${baseUrl}/dingtalk-callback.html`;
|
||||
|
||||
// 使用组织名称作为 state,便于回调时选择对应的配置
|
||||
const state = orgName || selectedOrg?.name || "default";
|
||||
// state 中保存组织和登录后的回跳地址,回调接口仍兼容旧的纯组织名 state。
|
||||
const state = encodeState({
|
||||
org: orgName || selectedOrg?.name || "default",
|
||||
callbackUrl: normalizeCallbackUrl(urlObj.searchParams.get("callbackUrl")),
|
||||
});
|
||||
|
||||
// 钉钉 OAuth 2.0 授权地址
|
||||
const url =
|
||||
|
||||
@ -26,6 +26,7 @@ interface UploadedImage {
|
||||
const DEFAULT_IMAGE_SIZE = "2K";
|
||||
const DEFAULT_TIMEOUT_MS = 300000;
|
||||
const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400;
|
||||
const OPENAI_IMAGE_DIMENSION_MULTIPLE = 16;
|
||||
const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i;
|
||||
const KONTEXT_MODEL_REGEX = /kontext/i;
|
||||
const SUPPORTED_ASPECT_RATIOS = [
|
||||
@ -83,16 +84,32 @@ function normalizeAspectRatio(value?: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseAspectRatioValue(ratio: string) {
|
||||
function parseAspectRatioPair(ratio: string) {
|
||||
const [width, height] = ratio.split(":").map((item) => Number(item));
|
||||
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;
|
||||
}
|
||||
|
||||
function roundEven(value: number) {
|
||||
return Math.max(2, Math.round(value / 2) * 2);
|
||||
function ceilToMultiple(value: number, multiple: number) {
|
||||
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) {
|
||||
@ -151,29 +168,15 @@ function resolveOpenAISize(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
|
||||
"4K": 2048,
|
||||
};
|
||||
|
||||
const safeRatio = parseAspectRatioValue(aspectRatio);
|
||||
const ratioPair = parseAspectRatioPair(aspectRatio) || { width: 1, height: 1 };
|
||||
const base = baseMap[imageSize] || 1536;
|
||||
const targetPixels = Math.max(base * base, OPENAI_IMAGE_MIN_TOTAL_PIXELS);
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
if (safeRatio >= 1) {
|
||||
width = roundEven(Math.sqrt(targetPixels * safeRatio));
|
||||
height = roundEven(width / safeRatio);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
const scale = ceilToMultiple(
|
||||
Math.sqrt(targetPixels / (ratioPair.width * ratioPair.height)),
|
||||
OPENAI_IMAGE_DIMENSION_MULTIPLE,
|
||||
);
|
||||
const width = ratioPair.width * scale;
|
||||
const height = ratioPair.height * scale;
|
||||
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
@ -530,9 +533,10 @@ export function createOpenAIImage({ apiKey, baseURL }: OpenAIImageConfig) {
|
||||
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
||||
const activeModelId = options.overrideModelId || modelId;
|
||||
const normalizedAspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
|
||||
const promptAspectRatio = inferAspectRatioFromPrompt(prompt);
|
||||
const aspectRatio =
|
||||
normalizedAspectRatio === "auto"
|
||||
? await inferAspectRatioFromImageUrl(imageUrls[0] || "")
|
||||
? promptAspectRatio || (await inferAspectRatioFromImageUrl(imageUrls[0] || ""))
|
||||
: normalizedAspectRatio;
|
||||
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
|
||||
const size =
|
||||
@ -546,6 +550,7 @@ export function createOpenAIImage({ apiKey, baseURL }: OpenAIImageConfig) {
|
||||
model: activeModelId,
|
||||
size,
|
||||
aspectRatio: aspectRatio || "auto",
|
||||
promptAspectRatio: promptAspectRatio || undefined,
|
||||
imageSize,
|
||||
useBltcyFluxCompatParams: shouldUseBltcyFluxCompatParams(baseURL, activeModelId),
|
||||
imageCount: imageUrls.length,
|
||||
|
||||
@ -127,7 +127,7 @@
|
||||
console.log('[DingTalk Callback] Got token:', data.token.substring(0, 10) + '...');
|
||||
|
||||
// 直接在这里完成 Auth.js 登录
|
||||
completeLogin(data.token);
|
||||
completeLogin(data.token, normalizeCallbackUrl(data.callbackUrl));
|
||||
} else {
|
||||
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 {
|
||||
statusText.textContent = '正在登录系统...';
|
||||
|
||||
@ -157,7 +168,7 @@
|
||||
body: new URLSearchParams({
|
||||
csrfToken: csrfToken,
|
||||
token: token,
|
||||
callbackUrl: '/',
|
||||
callbackUrl: callbackUrl,
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
@ -183,14 +194,14 @@
|
||||
// 使用 window.top.location 确保跳转整个页面(即使在 iframe 中)
|
||||
try {
|
||||
if (window.top && window.top.location) {
|
||||
window.top.location.replace('/');
|
||||
window.top.location.replace(callbackUrl);
|
||||
} else {
|
||||
window.location.replace('/');
|
||||
window.location.replace(callbackUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果跨域阻止,使用当前窗口跳转
|
||||
console.warn('[DingTalk Callback] Cross-origin blocked, using current window');
|
||||
window.location.replace('/');
|
||||
window.location.replace(callbackUrl);
|
||||
}
|
||||
} else {
|
||||
console.error('[DingTalk Callback] Session not created, retrying...');
|
||||
@ -201,7 +212,7 @@
|
||||
});
|
||||
const retrySession = await retrySessionRes.json();
|
||||
if (retrySession && retrySession.user) {
|
||||
window.location.replace('/');
|
||||
window.location.replace(callbackUrl);
|
||||
} else {
|
||||
throw new Error('登录失败:无法创建会话,请刷新页面重试');
|
||||
}
|
||||
@ -244,4 +255,4 @@
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user