@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
318 lines (277 loc) • 10.8 kB
text/typescript
import debug from 'debug';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { IncomingMessage, ServerResponse } from 'node:http';
import urlJoin from 'url-join';
import { appEnv } from '@/envs/app';
const log = debug('lobe-oidc:http-adapter');
/**
* 将 Next.js 请求头转换为标准 Node.js HTTP 头格式
*/
export const convertHeadersToNodeHeaders = (nextHeaders: Headers): Record<string, string> => {
const headers: Record<string, string> = {};
nextHeaders.forEach((value, key) => {
headers[key] = value;
});
return headers;
};
/**
* 创建用于 OIDC Provider 的 Node.js HTTP 请求对象
* @param req Next.js 请求对象
*/
export const createNodeRequest = async (req: NextRequest): Promise<IncomingMessage> => {
// 构建 URL 对象
const url = new URL(req.url);
// 计算相对于前缀的路径
let providerPath = url.pathname;
// 确保路径始终以/开头
if (!providerPath.startsWith('/')) {
providerPath = '/' + providerPath;
}
log('Creating Node.js request from Next.js request');
log('Original path: %s, Provider path: %s', url.pathname, providerPath);
// Attempt to parse and attach body for relevant methods
let parsedBody: any = undefined;
const methodsWithBody = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (methodsWithBody.includes(req.method)) {
const contentType = req.headers.get('content-type')?.split(';')[0]; // Get content type without charset etc.
log(`Attempting to parse body for ${req.method} with Content-Type: ${contentType}`);
try {
// Check if body exists and has size before attempting to parse
if (req.body && req.headers.get('content-length') !== '0') {
if (contentType === 'application/x-www-form-urlencoded') {
const formData = await req.formData();
parsedBody = {};
formData.forEach((value, key) => {
// If a key appears multiple times, keep the last one (standard form behavior)
// Or convert to array if oidc-provider expects it:
// if (parsedBody[key]) {
// if (!Array.isArray(parsedBody[key])) parsedBody[key] = [parsedBody[key]];
// parsedBody[key].push(value);
// } else {
// parsedBody[key] = value;
// }
parsedBody[key] = value;
});
log('Parsed form data body: %O', parsedBody);
} else if (contentType === 'application/json') {
parsedBody = await req.json();
log('Parsed JSON body: %O', parsedBody);
} else {
log(`Body parsing skipped for Content-Type: ${contentType}. Trying text() as fallback.`);
// Fallback: try reading as text if content type is unknown but body exists
parsedBody = await req.text();
log('Parsed body as text fallback.');
}
} else {
log('Request has no body or content-length is 0, skipping parsing.');
}
} catch (error) {
log('Error parsing request body: %O', error);
// Keep parsedBody as undefined, let oidc-provider handle the potential issue
}
}
const nodeRequest = {
// 基本属性
headers: convertHeadersToNodeHeaders(req.headers),
method: req.method,
// 模拟可读流行为 (oidc-provider might not rely on this if body is pre-parsed)
// eslint-disable-next-line @typescript-eslint/ban-types
on: (event: string, handler: Function) => {
if (event === 'end') {
// Simulate end immediately as body is already processed or will be attached
handler();
}
},
// 添加 Node.js 服务器所期望的额外属性
socket: {
remoteAddress: req.headers.get('x-forwarded-for') || '127.0.0.1',
},
url: providerPath + url.search,
...(parsedBody !== undefined && { body: parsedBody }), // Attach body if it exists
};
log('Node.js request created with method %s and path %s', nodeRequest.method, nodeRequest.url);
if (nodeRequest.body) {
log('Attached parsed body to Node.js request.');
}
// Cast back to IncomingMessage for the function's return signature
return nodeRequest as unknown as IncomingMessage;
};
/**
* 响应收集器接口,用于捕获 OIDC Provider 的响应
*/
export interface ResponseCollector {
nodeResponse: ServerResponse;
readonly responseBody: string | Buffer;
readonly responseHeaders: Record<string, string | string[]>;
readonly responseStatus: number;
}
/**
* 创建用于 OIDC Provider 的 Node.js HTTP 响应对象
* @param resolvePromise 当响应完成时调用的解析函数
*/
export const createNodeResponse = (resolvePromise: () => void): ResponseCollector => {
log('Creating Node.js response collector');
// 存储响应状态的对象
const state = {
responseBody: '' as string | Buffer,
responseHeaders: {} as Record<string, string | string[]>,
responseStatus: 200,
};
let promiseResolved = false;
const nodeResponse: any = {
end: (chunk?: string | Buffer) => {
log('NodeResponse.end called');
if (chunk) {
log('NodeResponse.end chunk: %s', typeof chunk === 'string' ? chunk : '(Buffer)');
// @ts-ignore
state.responseBody += chunk;
}
const locationHeader = state.responseHeaders['location'];
if (locationHeader && state.responseStatus === 200) {
log('Location header detected with status 200, overriding to 302');
state.responseStatus = 302;
}
if (!promiseResolved) {
log('Resolving response promise');
promiseResolved = true;
resolvePromise();
}
},
getHeader: (name: string) => {
const lowerName = name.toLowerCase();
return state.responseHeaders[lowerName];
},
getHeaderNames: () => {
return Object.keys(state.responseHeaders);
},
getHeaders: () => {
return state.responseHeaders;
},
headersSent: false,
removeHeader: (name: string) => {
const lowerName = name.toLowerCase();
log('Removing header: %s', lowerName);
delete state.responseHeaders[lowerName];
},
setHeader: (name: string, value: string | string[]) => {
const lowerName = name.toLowerCase();
log('Setting header: %s = %s', lowerName, value);
state.responseHeaders[lowerName] = value;
},
write: (chunk: string | Buffer) => {
log('NodeResponse.write called with chunk');
// @ts-ignore
state.responseBody += chunk;
},
writeHead: (status: number, headers?: Record<string, string | string[]>) => {
log('NodeResponse.writeHead called with status: %d', status);
state.responseStatus = status;
if (headers) {
const lowerCaseHeaders = Object.entries(headers).reduce(
(acc, [key, value]) => {
acc[key.toLowerCase()] = value;
return acc;
},
{} as Record<string, string | string[]>,
);
state.responseHeaders = { ...state.responseHeaders, ...lowerCaseHeaders };
}
(nodeResponse as any).headersSent = true;
},
} as unknown as ServerResponse;
log('Node.js response collector created successfully');
return {
nodeResponse,
get responseBody() {
return state.responseBody;
},
get responseHeaders() {
return state.responseHeaders;
},
get responseStatus() {
return state.responseStatus;
},
};
};
/**
* 创建用于调用 provider.interactionDetails 的上下文 (req, res)
* @param uid 交互 ID
*/
export const createContextForInteractionDetails = async (
uid: string,
): Promise<{ req: IncomingMessage; res: ServerResponse }> => {
log('Creating context for interaction details for uid: %s', uid);
const baseUrl = appEnv.APP_URL!;
log('Using base URL: %s', baseUrl);
// 从baseUrl提取主机名和协议用于headers
const parsedUrl = new URL(baseUrl);
const hostName = parsedUrl.host;
const protocol = parsedUrl.protocol.replace(':', '');
// 1. 获取真实的 Cookies
const cookieStore = await cookies();
const realCookies: Record<string, string> = {};
cookieStore.getAll().forEach((cookie) => {
realCookies[cookie.name] = cookie.value;
});
log('Real cookies found: %o', Object.keys(realCookies));
// 特别检查交互会话cookie
const interactionCookieName = `_interaction_${uid}`;
if (realCookies[interactionCookieName]) {
log('Found interaction session cookie: %s', interactionCookieName);
} else {
log('Warning: Interaction session cookie not found: %s', interactionCookieName);
}
// 2. 构建包含真实 Cookie 的 Headers
const headers = new Headers({
'host': hostName,
'x-forwarded-host': hostName,
'x-forwarded-proto': protocol,
});
const cookieString = Object.entries(realCookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
if (cookieString) {
headers.set('cookie', cookieString);
log('Setting cookie header');
} else {
log('No cookies found to set in header');
}
// 3. 创建模拟的 NextRequest
// 注意:这里的 IP, geo, ua 等信息可能是 oidc-provider 某些特性需要的,
// 如果遇到相关问题,可能需要从真实请求头中提取 (e.g., 'x-forwarded-for', 'user-agent')
const interactionUrl = urlJoin(baseUrl, `/oauth/consent/${uid}`);
log('Creating interaction URL: %s', interactionUrl);
const mockNextRequest = {
cookies: {
// 模拟 NextRequestCookies 接口
get: (name: string) => cookieStore.get(name)?.value,
getAll: () => cookieStore.getAll(),
has: (name: string) => cookieStore.has(name),
},
geo: {},
headers: headers,
ip: '127.0.0.1',
method: 'GET',
nextUrl: new URL(interactionUrl),
page: { name: undefined, params: undefined },
ua: undefined,
url: new URL(interactionUrl),
} as unknown as NextRequest;
log('Mock NextRequest created for url: %s', mockNextRequest.url);
// 4. 使用 createNodeRequest 创建模拟的 Node.js IncomingMessage
// pathPrefix 设置为 '/' 因为我们的 URL 已经是 Provider 期望的路径格式 /interaction/:uid
const req: IncomingMessage = await createNodeRequest(mockNextRequest);
// @ts-ignore - 将解析出的 cookies 附加到模拟的 Node.js 请求上
req.cookies = realCookies;
log('Node.js IncomingMessage created, attached real cookies');
// 5. 使用 createNodeResponse 创建模拟的 Node.js ServerResponse
let resolveFunc: () => void;
new Promise<void>((resolve) => {
resolveFunc = resolve;
});
const responseCollector: ResponseCollector = createNodeResponse(() => resolveFunc());
const res: ServerResponse = responseCollector.nodeResponse;
log('Node.js ServerResponse created');
return { req, res };
};