2026-07-07 19:35:25 +08:00

214 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextResponse, type NextRequest } from 'next/server'
import { DINGTALK_DIRECT_LOGIN_SOURCE, parseDingtalkOAuthState } from '@/lib/dingtalk-oauth'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { buildDingtalkAutoLoginPath } from '@/lib/safe-redirect'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const SURVEY_OPENING_PAGE_STYLE = `
html,body{margin:0;min-height:100%;background:#f7fbff;color:#111827;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
body{min-height:100vh;min-height:100dvh;overflow:hidden}
.topbar{height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;color:#fff;background:linear-gradient(90deg,#1677ff,#4f9cff);box-shadow:0 10px 24px rgba(22,119,255,.18)}
.topbar span:first-child{font-size:28px;line-height:1}
.topbar span:nth-child(2){font-size:15px;font-weight:600}
.topbar span:last-child{font-size:20px;line-height:1}
main{box-sizing:border-box;width:min(390px,100%);margin:0 auto;padding:48px 28px 0}
.brand{text-align:center}
.brand img{width:42px;height:42px;border-radius:10px;object-fit:cover}
.brand h1{margin:12px 0 0;font-size:23px;font-weight:600;line-height:1}
.brand p{margin:8px 0 0;color:#8a94a6;font-size:14px;font-weight:500}
.open-pill{min-height:58px;margin-top:40px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;border-radius:999px;background:#fff;box-shadow:0 12px 30px rgba(15,23,42,.12)}
.open-pill strong{display:flex;align-items:center;gap:12px;font-size:16px;font-weight:600}
.open-pill em{font-style:normal;color:#1677ff;font-size:15px;font-weight:600}
.spinner{width:28px;height:28px;border:4px solid #d7e8ff;border-top-color:#1677ff;border-radius:999px;animation:spin .85s linear infinite}
.message{margin:28px 0 0;text-align:center;color:#8a94a6;font-size:13px;font-weight:500;line-height:1.7}
.ghost-card{margin-top:44px;padding:20px;border-radius:18px;background:rgba(255,255,255,.9);box-shadow:0 18px 42px rgba(15,23,42,.08)}
.ghost-title{font-size:15px;font-weight:600;color:#7b8494}
.ghost-line{height:12px;border-radius:999px;background:#eef3f9}
.ghost-line.one{margin-top:24px}
.ghost-line.two{width:74%;margin-top:12px}
.ghost-list{margin-top:32px;display:grid;gap:20px}
.ghost-row{display:flex;align-items:center;gap:12px}
.ghost-dot{width:16px;height:16px;border-radius:999px;border:1px solid #e3eaf3}
.ghost-row .ghost-line{flex:1;margin:0}
.ghost-row:last-child .ghost-line{flex:.67}
@keyframes spin{to{transform:rotate(360deg)}}
`
const SURVEY_OPENING_PAGE_MARKUP = `
<div class="topbar"><span>‹</span><span>素材库</span><span>•••</span></div>
<main>
<div class="brand">
<img src="/api/favicon" alt="素材库" />
<h1>素材库</h1>
<p>内部素材协作平台</p>
</div>
<div class="open-pill">
<strong><span class="spinner" aria-hidden="true"></span>正在打开问卷</strong>
<em>马上进入</em>
</div>
<p class="message">正在打开问卷...</p>
<div class="ghost-card" aria-hidden="true">
<div class="ghost-title">绩效考核问卷</div>
<div class="ghost-line one"></div>
<div class="ghost-line two"></div>
<div class="ghost-list">
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
</div>
</div>
</main>`
function appendLoginSetCookie(target: NextResponse, source: Response) {
const getSetCookie = (source.headers as any).getSetCookie
const cookies = typeof getSetCookie === 'function'
? getSetCookie.call(source.headers)
: [source.headers.get('set-cookie')].filter(Boolean)
for (const cookie of cookies) {
target.headers.append('set-cookie', cookie)
}
}
function getForwardedHeaders(request: NextRequest) {
const publicUrl = new URL(getPublicAppOrigin())
return {
'x-forwarded-proto': publicUrl.protocol.replace(':', ''),
'x-forwarded-host': publicUrl.host,
'x-real-ip': request.headers.get('x-real-ip') || '',
'x-forwarded-for': request.headers.get('x-forwarded-for') || '',
}
}
function getInternalLoginUrl() {
const port = process.env.PORT || '3000'
return `http://127.0.0.1:${port}/api/dingtalk/login`
}
function createCookieLandingResponse(redirectPath: string, loginResponse: Response) {
const escapedPath = JSON.stringify(redirectPath).replace(/</g, '\\u003c')
const response = new NextResponse(
`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>正在打开问卷</title>
<meta http-equiv="Cache-Control" content="no-store" />
<style>${SURVEY_OPENING_PAGE_STYLE}</style>
</head>
<body>
${SURVEY_OPENING_PAGE_MARKUP}
<script>
window.location.replace(${escapedPath});
</script>
</body>
</html>`,
{
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
},
},
)
appendLoginSetCookie(response, loginResponse)
return response
}
function createDingtalkDirectErrorPage(message: string) {
const safeMessage = JSON.stringify(message || '暂时无法打开问卷,请联系管理员检查钉钉配置').replace(/</g, '\\u003c')
return new NextResponse(
`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>无法打开问卷</title>
<meta http-equiv="Cache-Control" content="no-store" />
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(440px,calc(100vw - 48px));padding:28px;text-align:center}
h1{font-size:22px;margin:0 0 12px}
p{margin:0;color:#475569;line-height:1.8}
</style>
</head>
<body>
<main>
<h1>无法打开问卷</h1>
<p id="message"></p>
</main>
<script>
document.getElementById('message').textContent = ${safeMessage};
</script>
</body>
</html>`,
{
status: 401,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
},
},
)
}
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get('code') || request.nextUrl.searchParams.get('authCode')
const { redirectPath, source } = parseDingtalkOAuthState(request.nextUrl.searchParams.get('state'))
const publicOrigin = getPublicAppOrigin()
if (!code) {
if (source === DINGTALK_DIRECT_LOGIN_SOURCE) {
return createDingtalkDirectErrorPage('暂时无法打开问卷,请从最新通知重新进入。')
}
return NextResponse.redirect(new URL(buildDingtalkAutoLoginPath(redirectPath), publicOrigin))
}
try {
const loginResponse = await fetch(getInternalLoginUrl(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getForwardedHeaders(request),
},
body: JSON.stringify({ code, state: request.nextUrl.searchParams.get('state') || '' }),
cache: 'no-store',
})
const data = await loginResponse.json().catch(() => null)
if (!loginResponse.ok || !data?.success || data.needCompleteProfile) {
if (source === DINGTALK_DIRECT_LOGIN_SOURCE) {
return createDingtalkDirectErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。')
}
const loginPath = buildDingtalkAutoLoginPath(redirectPath)
const url = new URL(loginPath, publicOrigin)
if (data?.message) {
url.searchParams.set('error', data.message)
}
return NextResponse.redirect(url)
}
if (source === DINGTALK_DIRECT_LOGIN_SOURCE) {
return createCookieLandingResponse(redirectPath, loginResponse)
}
const redirectResponse = NextResponse.redirect(new URL(redirectPath, publicOrigin))
appendLoginSetCookie(redirectResponse, loginResponse)
redirectResponse.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
return redirectResponse
} catch (error) {
console.error('[Dingtalk] 服务端授权回调失败:', error)
if (source === DINGTALK_DIRECT_LOGIN_SOURCE) {
return createDingtalkDirectErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。')
}
return NextResponse.redirect(new URL(buildDingtalkAutoLoginPath(redirectPath), publicOrigin))
}
}