import type { H3Event } from "h3"; import { createError, deleteCookie, getCookie, setCookie } from "h3"; /** 上游没有声明 session 过期时间时,本地登录态默认保留 3 天 */ const DEFAULT_AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 3; /** 本站保存 NewAPI session 的 httpOnly cookie 名称 */ export const NEWAPI_SESSION_COOKIE = "newapi_session"; /** 本站保存 NewAPI 用户 ID 的 httpOnly cookie 名称,用于服务端组装 New-Api-User */ export const NEWAPI_USER_ID_COOKIE = "newapi_user_id"; type CookieOptions = NonNullable[3]>; export interface INewApiCookieMeta { /** 上游 session cookie 的 Max-Age,单位秒 */ maxAge?: number; /** 上游 session cookie 的 Expires 过期时间 */ expires?: Date; } /** * 从上游 Set-Cookie 响应头中提取指定 cookie 的值。 * * 只返回 cookie value,不返回 Path/Expires 等属性,避免把元数据写进 * 本站 newapi_session。 */ export const extractCookieValueFromSetCookie = ( headers: Headers, name: string ): string | null => { const setCookie = findSetCookie(headers, name); if (!setCookie) return null; const firstPart = setCookie.split(";")[0] ?? ""; const equalsIndex = firstPart.indexOf("="); if (equalsIndex === -1) return null; return firstPart.slice(equalsIndex + 1).trim() || null; }; /** * 从上游 Set-Cookie 中读取过期时间元数据。 * * 本站 cookie 优先沿用上游 Max-Age/Expires;没有则在写入时默认 3 天。 */ export const extractCookieMetaFromSetCookie = ( headers: Headers, name: string ): INewApiCookieMeta | undefined => { const setCookie = findSetCookie(headers, name); if (!setCookie) return undefined; const meta: INewApiCookieMeta = {}; for (const part of setCookie.split(";").slice(1)) { const [rawKey, ...rawValue] = part.trim().split("="); const key = rawKey?.toLowerCase(); const value = rawValue.join("="); if (key === "max-age") { const maxAge = Number.parseInt(value, 10); if (Number.isFinite(maxAge)) meta.maxAge = maxAge; } if (key === "expires") { const expires = new Date(value); if (!Number.isNaN(expires.getTime())) meta.expires = expires; } } return meta.maxAge !== undefined || meta.expires ? meta : undefined; }; /** * 写入本站认证 cookie。 * * session 与 userId 都设置为 httpOnly,前端只通过 /api/auth/me 恢复状态, * 不直接接触 NewAPI session 明文。 */ export const setNewApiAuthCookies = ( event: H3Event, session: string, userId: number | string, upstreamCookieMeta?: INewApiCookieMeta ) => { const options = buildAuthCookieOptions(upstreamCookieMeta); setCookie(event, NEWAPI_SESSION_COOKIE, session, options); setCookie(event, NEWAPI_USER_ID_COOKIE, String(userId), options); }; /** 清理本站保存的 NewAPI 登录态,登出和鉴权失败时都调用 */ export const clearNewApiAuthCookies = (event: H3Event) => { const options: CookieOptions = { path: "/", sameSite: "lax", secure: process.env.NODE_ENV === "production" }; deleteCookie(event, NEWAPI_SESSION_COOKIE, options); deleteCookie(event, NEWAPI_USER_ID_COOKIE, options); }; /** 从本项目 httpOnly cookie 中读取 NewAPI 用户 ID,缺失或非法时统一视为未登录 */ export const getNewApiUserIdFromCookie = (event: H3Event): number => { const userId = Number.parseInt( getCookie(event, NEWAPI_USER_ID_COOKIE) ?? "", 10 ); if (!Number.isInteger(userId) || userId <= 0) { throw createError({ statusCode: 401, statusMessage: "未登录" }); } return userId; }; /** 统一生成本站认证 cookie 选项,保证安全属性在两个 cookie 上一致 */ const buildAuthCookieOptions = (meta?: INewApiCookieMeta): CookieOptions => { const options: CookieOptions = { httpOnly: true, path: "/", sameSite: "lax", secure: process.env.NODE_ENV === "production" }; if (typeof meta?.maxAge === "number") { options.maxAge = meta.maxAge; } else if (meta?.expires) { options.expires = meta.expires; } else { options.maxAge = DEFAULT_AUTH_COOKIE_MAX_AGE; } return options; }; /** 在多个 Set-Cookie 值中找到指定名称的 cookie */ const findSetCookie = (headers: Headers, name: string): string | null => { const lowerName = name.toLowerCase(); for (const setCookie of getSetCookieValues(headers)) { const firstPart = setCookie.split(";")[0] ?? ""; const equalsIndex = firstPart.indexOf("="); if (equalsIndex === -1) continue; const cookieName = firstPart.slice(0, equalsIndex).trim().toLowerCase(); if (cookieName === lowerName) return setCookie; } return null; }; /** * 读取 Set-Cookie 头。 * * Nuxt 服务端运行时优先使用 getSetCookie;没有该方法时读取合并后的 * set-cookie 字符串再拆分。 */ const getSetCookieValues = (headers: Headers): string[] => { const headersWithSetCookie = headers as Headers & { getSetCookie?: () => string[]; }; const values = headersWithSetCookie.getSetCookie?.(); if (values?.length) return values; const headerValue = headers.get("set-cookie"); return headerValue ? splitSetCookieHeader(headerValue) : []; }; /** * 拆分被合并成一个字符串的 Set-Cookie。 * * Expires 属性本身包含逗号,不能简单按逗号 split;这里只在后续片段 * 看起来像新 cookie 名称时才切分。 */ const splitSetCookieHeader = (headerValue: string): string[] => { const cookies: string[] = []; let start = 0; for (let index = 0; index < headerValue.length; index += 1) { if (headerValue[index] !== ",") continue; const rest = headerValue.slice(index + 1); if (/^\s*[^=;,\s]+=/.test(rest)) { cookies.push(headerValue.slice(start, index).trim()); start = index + 1; } } cookies.push(headerValue.slice(start).trim()); return cookies.filter(Boolean); };