Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
500 lines
16 KiB
TypeScript
500 lines
16 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import {
|
|
authorizeDingtalkH5UserForNextAuth,
|
|
loginDingtalkUserByH5AuthCode,
|
|
} from '@/lib/dingtalk-login'
|
|
import { createDingtalkPerformanceSessionToken } from '@/lib/dingtalk-performance-session'
|
|
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
|
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
|
|
|
|
export const runtime = 'nodejs'
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const H5_LOGIN_MAX_BODY_BYTES = 64 * 1024
|
|
|
|
class H5LoginRequestError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly status: number,
|
|
readonly code: string,
|
|
) {
|
|
super(message)
|
|
}
|
|
}
|
|
|
|
const SURVEY_OPENING_PAGE_STYLE = `
|
|
html,body{margin:0;min-height:100%;background:#f6f8fb;color:#111827;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
body{min-height:100vh;min-height:100dvh;overflow:hidden}
|
|
.topbar{height:64px;box-sizing:border-box;display:flex;align-items:center;padding:0 20px;color:#fff;background:#000}
|
|
.topbar span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.topbar strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:18px;font-weight:650;line-height:1.15;letter-spacing:-.01em}
|
|
main{box-sizing:border-box;width:min(430px,100%);margin:0 auto;padding:32px 20px 0}
|
|
.shell{padding:24px;border:1px solid #e3eaf3;border-radius:26px;background:#fff;box-shadow:0 18px 48px rgba(15,23,42,.10)}
|
|
.kicker{font-size:14px;font-weight:650;color:#1677ff}
|
|
h1{margin:12px 0 0;font-size:28px;line-height:1.18;font-weight:700;letter-spacing:-.01em;color:#101828}
|
|
#message{margin:12px 0 0;color:#667085;font-size:14px;font-weight:500;line-height:1.7}
|
|
.open-box{margin-top:24px;padding:16px;border-radius:22px;background:#f6f8fb}
|
|
.open-row{display:flex;align-items:center;justify-content:space-between;gap:16px}
|
|
.open-row strong{font-size:15px;font-weight:650;color:#101828}
|
|
.open-row em{font-style:normal;color:#1677ff;font-size:14px;font-weight:650}
|
|
.progress{height:6px;margin-top:16px;overflow:hidden;border-radius:999px;background:#e8f1ff}
|
|
.progress span{display:block;width:66%;height:100%;border-radius:inherit;background:#1677ff;animation:pulse 1.1s ease-in-out infinite}
|
|
.ghost-card{margin-top:28px;padding:20px;border:1px solid #eef2f7;border-radius:18px;background:#fff}
|
|
.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 pulse{0%,100%{opacity:.55}50%{opacity:1}}
|
|
`
|
|
|
|
const SURVEY_OPENING_PAGE_MARKUP = `
|
|
<div class="topbar"><span><strong>由壹点「素材库」</strong></span></div>
|
|
<main>
|
|
<section class="shell">
|
|
<div class="kicker">考核调研</div>
|
|
<h1>正在打开问卷</h1>
|
|
<p id="message">正在打开问卷...</p>
|
|
<div class="open-box">
|
|
<div class="open-row">
|
|
<strong>准备问卷内容</strong>
|
|
<em>马上进入</em>
|
|
</div>
|
|
<div class="progress"><span></span></div>
|
|
</div>
|
|
<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>
|
|
</section>
|
|
</main>`
|
|
|
|
function getLoginSetCookies(source: Response): string[] {
|
|
const getSetCookie = (source.headers as any).getSetCookie
|
|
const cookies = typeof getSetCookie === 'function'
|
|
? getSetCookie.call(source.headers)
|
|
: [source.headers.get('set-cookie')].filter(Boolean)
|
|
|
|
return cookies.filter((cookie: unknown): cookie is string => typeof cookie === 'string' && cookie.length > 0)
|
|
}
|
|
|
|
function stringifyForInlineScript(value: string) {
|
|
return JSON.stringify(value).replace(/</g, '\\u003c')
|
|
}
|
|
|
|
function buildPublicUrl(path: string, request?: NextRequest) {
|
|
try {
|
|
return new URL(path, getPublicAppOrigin()).toString()
|
|
} catch {
|
|
return new URL(path, request?.url || 'https://sc.jiansun.vip').toString()
|
|
}
|
|
}
|
|
|
|
function appendSetCookies(target: NextResponse, cookies: string[]) {
|
|
const cookieNames: string[] = []
|
|
|
|
for (const cookie of cookies) {
|
|
target.headers.append('set-cookie', cookie)
|
|
const cookieName = String(cookie).split('=', 1)[0]?.trim()
|
|
if (cookieName) {
|
|
cookieNames.push(cookieName)
|
|
}
|
|
}
|
|
|
|
return cookieNames
|
|
}
|
|
|
|
function getRequestHeaderSnapshot(request: NextRequest) {
|
|
return {
|
|
host: request.headers.get('host') || '',
|
|
forwardedHost: request.headers.get('x-forwarded-host') || '',
|
|
forwardedProto: request.headers.get('x-forwarded-proto') || '',
|
|
userAgent: request.headers.get('user-agent')?.includes('DingTalk') ? 'DingTalk' : 'other',
|
|
}
|
|
}
|
|
|
|
function createSessionTicketTransferPage({
|
|
request,
|
|
redirectPath,
|
|
loginResponse,
|
|
}: {
|
|
request: NextRequest
|
|
redirectPath: string
|
|
loginResponse: Response
|
|
}) {
|
|
const cookies = getLoginSetCookies(loginResponse)
|
|
if (cookies.length === 0) {
|
|
console.error('[Dingtalk H5] 登录成功但没有生成会话Cookie', { redirectPath })
|
|
return createH5LoginErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。')
|
|
}
|
|
|
|
const redirectUrl = buildPublicUrl(redirectPath, request)
|
|
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>
|
|
(function () {
|
|
var targetUrl = ${stringifyForInlineScript(redirectUrl)};
|
|
var message = document.getElementById('message');
|
|
var attempts = 0;
|
|
var maxAttempts = 12;
|
|
|
|
function setMessage(text) {
|
|
if (message) message.textContent = text;
|
|
}
|
|
|
|
function wait(ms) {
|
|
return new Promise(function (resolve) {
|
|
window.setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
async function confirmSessionThenEnter() {
|
|
while (attempts < maxAttempts) {
|
|
attempts += 1;
|
|
setMessage('正在打开问卷...');
|
|
|
|
try {
|
|
var response = await fetch('/api/auth/session?source=dingtalk_h5_entry&attempt=' + attempts + '&_=' + Date.now(), {
|
|
cache: 'no-store',
|
|
credentials: 'include'
|
|
});
|
|
var data = response.ok ? await response.json().catch(function () { return null; }) : null;
|
|
|
|
if (data && data.user) {
|
|
setMessage('正在打开问卷...');
|
|
window.location.replace(targetUrl);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
// 下一轮继续确认,避免钉钉内置容器偶发网络抖动导致误判。
|
|
}
|
|
|
|
await wait(450);
|
|
}
|
|
|
|
setMessage('暂时无法打开问卷,请关闭当前页面后从最新通知重新进入。');
|
|
}
|
|
|
|
window.setTimeout(confirmSessionThenEnter, 250);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`,
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
|
},
|
|
},
|
|
)
|
|
|
|
const cookieNames = appendSetCookies(response, cookies)
|
|
console.log('[Dingtalk H5] 已在免登落地页写入会话Cookie:', {
|
|
redirectPath,
|
|
redirectUrl,
|
|
cookieNames,
|
|
requestHeaders: getRequestHeaderSnapshot(request),
|
|
})
|
|
return response
|
|
}
|
|
|
|
function createPerformanceSurveySessionTransferPage({
|
|
request,
|
|
redirectPath,
|
|
token,
|
|
}: {
|
|
request: NextRequest
|
|
redirectPath: string
|
|
token: string
|
|
}) {
|
|
const redirectUrl = buildPublicUrl(redirectPath, request)
|
|
console.log('[Dingtalk H5] 已生成考核调研钉钉访问凭证:', {
|
|
redirectPath,
|
|
redirectUrl,
|
|
requestHeaders: getRequestHeaderSnapshot(request),
|
|
})
|
|
|
|
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>${SURVEY_OPENING_PAGE_STYLE}</style>
|
|
</head>
|
|
<body>
|
|
${SURVEY_OPENING_PAGE_MARKUP}
|
|
<script>
|
|
(function () {
|
|
var targetUrl = ${stringifyForInlineScript(redirectUrl)};
|
|
var token = ${stringifyForInlineScript(token)};
|
|
var storageKey = 'performanceSurveyDingtalkToken';
|
|
var message = document.getElementById('message');
|
|
|
|
function setMessage(text) {
|
|
if (message) message.textContent = text;
|
|
}
|
|
|
|
try {
|
|
window.sessionStorage.setItem(storageKey, token);
|
|
window.localStorage.setItem(storageKey, token);
|
|
} catch (error) {
|
|
setMessage('暂时无法打开问卷,请关闭当前页面后从最新通知重新进入。');
|
|
return;
|
|
}
|
|
|
|
setMessage('正在打开问卷...');
|
|
window.setTimeout(function () {
|
|
window.location.replace(targetUrl);
|
|
}, 120);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`,
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
function createPublicRedirectResponse({
|
|
request,
|
|
redirectPath,
|
|
}: {
|
|
request: NextRequest
|
|
redirectPath: string
|
|
}) {
|
|
const redirectUrl = buildPublicUrl(redirectPath, request)
|
|
const response = NextResponse.redirect(redirectUrl, { status: 303 })
|
|
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
|
return response
|
|
}
|
|
|
|
function createH5LoginErrorPage(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',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
async function readH5LoginCode(request: NextRequest) {
|
|
assertBoundedH5LoginBody(request)
|
|
const contentType = request.headers.get('content-type') || ''
|
|
if (contentType.includes('application/json')) {
|
|
const body = await request.json().catch(() => ({}))
|
|
return String(body?.code || '').trim()
|
|
}
|
|
|
|
if (
|
|
contentType.includes('application/x-www-form-urlencoded') ||
|
|
contentType.includes('multipart/form-data')
|
|
) {
|
|
const body = await request.formData().catch(() => null)
|
|
return String(body?.get('code') || '').trim()
|
|
}
|
|
|
|
const body = await request.text().catch(() => '')
|
|
return new URLSearchParams(body).get('code')?.trim() || ''
|
|
}
|
|
|
|
function assertBoundedH5LoginBody(request: NextRequest): void {
|
|
const rawContentLength = request.headers.get('content-length')
|
|
if (!rawContentLength || !/^\d+$/.test(rawContentLength.trim())) {
|
|
throw new H5LoginRequestError(
|
|
'登录请求缺少有效的 Content-Length',
|
|
411,
|
|
'CONTENT_LENGTH_REQUIRED',
|
|
)
|
|
}
|
|
|
|
const contentLength = Number(rawContentLength)
|
|
if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
|
|
throw new H5LoginRequestError(
|
|
'登录请求的 Content-Length 无效',
|
|
411,
|
|
'CONTENT_LENGTH_REQUIRED',
|
|
)
|
|
}
|
|
if (contentLength > H5_LOGIN_MAX_BODY_BYTES) {
|
|
throw new H5LoginRequestError(
|
|
'登录请求内容过大',
|
|
413,
|
|
'REQUEST_TOO_LARGE',
|
|
)
|
|
}
|
|
}
|
|
|
|
function isHtmlNavigationRequest(request: NextRequest) {
|
|
const accept = request.headers.get('accept') || ''
|
|
const fetchMode = request.headers.get('sec-fetch-mode') || ''
|
|
return accept.includes('text/html') || fetchMode === 'navigate'
|
|
}
|
|
|
|
async function handleH5LoginPage(request: NextRequest, code: string) {
|
|
const redirectPath = normalizeInternalRedirectPath(
|
|
request.nextUrl.searchParams.get('redirect'),
|
|
'/performance-survey',
|
|
)
|
|
const source = request.nextUrl.searchParams.get('source') || ''
|
|
|
|
console.log('[Dingtalk H5] 收到免登入口请求:', {
|
|
hasCode: Boolean(code),
|
|
redirectPath,
|
|
source,
|
|
requestHeaders: getRequestHeaderSnapshot(request),
|
|
})
|
|
|
|
if (!code) {
|
|
return createH5LoginErrorPage('暂时无法打开页面,请从最新通知重新进入。')
|
|
}
|
|
|
|
try {
|
|
if (redirectPath.startsWith('/performance-survey')) {
|
|
const clientIp =
|
|
request.headers.get('x-forwarded-for') ||
|
|
request.headers.get('x-real-ip') ||
|
|
null
|
|
const user = await authorizeDingtalkH5UserForNextAuth(code, {
|
|
appSource: 'workNotice',
|
|
clientIp,
|
|
})
|
|
const token = createDingtalkPerformanceSessionToken(user)
|
|
return createPerformanceSurveySessionTransferPage({ request, redirectPath, token })
|
|
}
|
|
|
|
const loginResponse = await loginDingtalkUserByH5AuthCode(request, code, {
|
|
appSource: 'workNotice',
|
|
})
|
|
const data = await loginResponse.json().catch(() => null)
|
|
|
|
if (!loginResponse.ok || !data?.success) {
|
|
console.warn('[Dingtalk H5] 免登登录未成功:', {
|
|
status: loginResponse.status,
|
|
redirectPath,
|
|
message: data?.message || '',
|
|
})
|
|
return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。')
|
|
}
|
|
|
|
if (data.needCompleteProfile) {
|
|
const userInfo = encodeURIComponent(JSON.stringify(data.user || {}))
|
|
const redirectResponse = createPublicRedirectResponse({
|
|
request,
|
|
redirectPath: `/complete-profile?userInfo=${userInfo}`,
|
|
})
|
|
appendSetCookies(redirectResponse, getLoginSetCookies(loginResponse))
|
|
return redirectResponse
|
|
}
|
|
|
|
return createSessionTicketTransferPage({ request, redirectPath, loginResponse })
|
|
} catch (error: any) {
|
|
console.error('钉钉H5免登页面跳转失败:', error)
|
|
return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。')
|
|
}
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const code = String(
|
|
request.nextUrl.searchParams.get('code') ||
|
|
request.nextUrl.searchParams.get('authCode') ||
|
|
'',
|
|
).trim()
|
|
|
|
return handleH5LoginPage(request, code)
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const code = await readH5LoginCode(request)
|
|
|
|
if (isHtmlNavigationRequest(request)) {
|
|
return handleH5LoginPage(request, code)
|
|
}
|
|
|
|
if (!code) {
|
|
return NextResponse.json(
|
|
{ success: false, message: '缺少钉钉免登授权码' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const response = await loginDingtalkUserByH5AuthCode(request, code, {
|
|
appSource: 'workNotice',
|
|
})
|
|
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
|
return response
|
|
} catch (error: any) {
|
|
console.error('钉钉H5免登失败:', error)
|
|
if (error instanceof H5LoginRequestError) {
|
|
return NextResponse.json(
|
|
{ success: false, message: error.message, code: error.code },
|
|
{ status: error.status },
|
|
)
|
|
}
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
message: error.message || '钉钉免登失败,请稍后再试',
|
|
},
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
}
|