214 lines
8.7 KiB
TypeScript
214 lines
8.7 KiB
TypeScript
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))
|
||
}
|
||
}
|