257 lines
8.6 KiB
JavaScript
257 lines
8.6 KiB
JavaScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
function domainMatches(hostname, cookieDomain) {
|
|
const normalized = String(cookieDomain || "").replace(/^\./, "").toLowerCase();
|
|
const host = String(hostname || "").toLowerCase();
|
|
if (!normalized || !host) return false;
|
|
if (normalized === host) return true;
|
|
if (normalized === "localhost") return host === "localhost";
|
|
return host.endsWith(`.${normalized}`);
|
|
}
|
|
|
|
export function normalizeSameSite(value) {
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
if (normalized === "strict") return "Strict";
|
|
if (normalized === "none") return "None";
|
|
return "Lax";
|
|
}
|
|
|
|
export function normalizeCookie(cookie) {
|
|
if (!cookie?.name || !cookie?.domain) return null;
|
|
return {
|
|
path: "/",
|
|
expires: -1,
|
|
httpOnly: false,
|
|
secure: false,
|
|
sameSite: "Lax",
|
|
...cookie,
|
|
domain: String(cookie.domain).replace(/^\./, "").toLowerCase(),
|
|
path: cookie.path || "/",
|
|
expires:
|
|
Number.isFinite(cookie.expires) || cookie.expires === -1
|
|
? cookie.expires
|
|
: Number(cookie.expires) || -1,
|
|
httpOnly: Boolean(cookie.httpOnly),
|
|
secure: Boolean(cookie.secure),
|
|
sameSite: normalizeSameSite(cookie.sameSite),
|
|
};
|
|
}
|
|
|
|
function findCookieIndex(cookies, candidate) {
|
|
return cookies.findIndex(
|
|
(cookie) =>
|
|
cookie.name === candidate.name &&
|
|
String(cookie.domain || "").replace(/^\./, "").toLowerCase() === candidate.domain &&
|
|
(cookie.path || "/") === candidate.path
|
|
);
|
|
}
|
|
|
|
export function cookieHeaderFor(urlString, cookies) {
|
|
const url = new URL(urlString);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return cookies
|
|
.map(normalizeCookie)
|
|
.filter((cookie) => {
|
|
if (!cookie) return false;
|
|
if (!domainMatches(url.hostname, cookie.domain)) return false;
|
|
if (!url.pathname.startsWith(cookie.path || "/")) return false;
|
|
if (cookie.secure && url.protocol !== "https:") return false;
|
|
if (cookie.expires && cookie.expires !== -1 && cookie.expires < now) {
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
.sort((left, right) => (right.path?.length || 0) - (left.path?.length || 0))
|
|
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
|
.join("; ");
|
|
}
|
|
|
|
export function splitSetCookieHeader(headerValue) {
|
|
if (!headerValue) return [];
|
|
const parts = [];
|
|
let current = "";
|
|
let inExpires = false;
|
|
for (let index = 0; index < headerValue.length; index += 1) {
|
|
const char = headerValue[index];
|
|
const next = headerValue.slice(index);
|
|
if (!inExpires && next.toLowerCase().startsWith("expires=")) {
|
|
inExpires = true;
|
|
}
|
|
if (char === "," && !inExpires) {
|
|
const trimmed = current.trim();
|
|
if (trimmed) parts.push(trimmed);
|
|
current = "";
|
|
continue;
|
|
}
|
|
current += char;
|
|
if (inExpires && char === ";") {
|
|
inExpires = false;
|
|
}
|
|
}
|
|
const trimmed = current.trim();
|
|
if (trimmed) parts.push(trimmed);
|
|
return parts;
|
|
}
|
|
|
|
function defaultCookiePath(url) {
|
|
const pathname = url.pathname || "/";
|
|
if (!pathname.startsWith("/") || pathname === "/") return "/";
|
|
const lastSlash = pathname.lastIndexOf("/");
|
|
return lastSlash <= 0 ? "/" : pathname.slice(0, lastSlash);
|
|
}
|
|
|
|
export function mergeCookiesFromHeader(state, urlString, headerValue) {
|
|
const url = new URL(urlString);
|
|
const parts = splitSetCookieHeader(headerValue);
|
|
let changed = false;
|
|
for (const part of parts) {
|
|
const segments = part.split(";").map((segment) => segment.trim()).filter(Boolean);
|
|
const [nameValue, ...attributes] = segments;
|
|
const eqIndex = nameValue?.indexOf("=") ?? -1;
|
|
if (eqIndex <= 0) continue;
|
|
const name = nameValue.slice(0, eqIndex);
|
|
const value = nameValue.slice(eqIndex + 1);
|
|
const nextCookie = {
|
|
name,
|
|
value,
|
|
domain: url.hostname.toLowerCase(),
|
|
path: defaultCookiePath(url),
|
|
expires: -1,
|
|
httpOnly: false,
|
|
secure: url.protocol === "https:",
|
|
sameSite: "Lax",
|
|
};
|
|
for (const attribute of attributes) {
|
|
const separator = attribute.indexOf("=");
|
|
const rawKey = (separator >= 0 ? attribute.slice(0, separator) : attribute).trim();
|
|
const rawValue = separator >= 0 ? attribute.slice(separator + 1).trim() : "";
|
|
const key = rawKey.toLowerCase();
|
|
if (key === "domain") nextCookie.domain = rawValue.replace(/^\./, "").toLowerCase() || url.hostname.toLowerCase();
|
|
if (key === "path") nextCookie.path = rawValue || "/";
|
|
if (key === "secure") nextCookie.secure = true;
|
|
if (key === "httponly") nextCookie.httpOnly = true;
|
|
if (key === "samesite") nextCookie.sameSite = normalizeSameSite(rawValue);
|
|
if (key === "expires") {
|
|
const seconds = Math.floor(new Date(rawValue).getTime() / 1000);
|
|
nextCookie.expires = Number.isFinite(seconds) ? seconds : -1;
|
|
}
|
|
if (key === "max-age") {
|
|
const maxAge = Number(rawValue);
|
|
nextCookie.maxAge = maxAge;
|
|
nextCookie.expires = Number.isFinite(maxAge)
|
|
? Math.floor(Date.now() / 1000) + maxAge
|
|
: nextCookie.expires;
|
|
}
|
|
}
|
|
const normalized = normalizeCookie(nextCookie);
|
|
if (!normalized) continue;
|
|
const existingIndex = findCookieIndex(state.cookies, normalized);
|
|
if (normalized.expires === 0 || normalized.maxAge === 0) {
|
|
if (existingIndex >= 0) {
|
|
state.cookies.splice(existingIndex, 1);
|
|
changed = true;
|
|
}
|
|
continue;
|
|
}
|
|
if (existingIndex >= 0) {
|
|
const previous = JSON.stringify(normalizeCookie(state.cookies[existingIndex]));
|
|
state.cookies[existingIndex] = { ...state.cookies[existingIndex], ...normalized };
|
|
if (previous !== JSON.stringify(normalizeCookie(state.cookies[existingIndex]))) {
|
|
changed = true;
|
|
}
|
|
} else {
|
|
state.cookies.push(normalized);
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function getHeaderValue(headers, name) {
|
|
if (!headers) return null;
|
|
if (typeof headers.get === "function") {
|
|
return headers.get(name) || headers.get(name.toLowerCase()) || headers.get(name.toUpperCase()) || null;
|
|
}
|
|
return headers[name] || headers[name.toLowerCase()] || headers[name.toUpperCase()] || null;
|
|
}
|
|
|
|
export function extractClientBin(html, headers = null) {
|
|
const fromHeader = getHeaderValue(headers, "x-bitech-svrbin");
|
|
if (fromHeader) return fromHeader;
|
|
return String(html || "").match(/oracle-jet-bundles\/[^.]+\.([a-z0-9]+)\//i)?.[1] || null;
|
|
}
|
|
|
|
export function extractCsrfToken(value) {
|
|
if (!value || typeof value !== "object") return null;
|
|
for (const [key, candidate] of Object.entries(value)) {
|
|
if (typeof candidate === "string" && key.toLowerCase() === "csrftoken") {
|
|
return candidate;
|
|
}
|
|
const nested = extractCsrfToken(candidate);
|
|
if (nested) return nested;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function readJson(filePath, label) {
|
|
let raw;
|
|
try {
|
|
raw = await fs.readFile(filePath, "utf8");
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") {
|
|
throw new Error(
|
|
`Could not find ${label} at ${filePath}. Run scripts/refresh-auth-direct.mjs first to create the session files.`
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error(`Could not parse ${label} at ${filePath}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
export async function persistSessionArtifacts(statePath, metaPath, state, meta, { ensureParentDir } = {}) {
|
|
if (typeof ensureParentDir === "function") {
|
|
await ensureParentDir(statePath);
|
|
await ensureParentDir(metaPath);
|
|
}
|
|
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
await fs.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
export function replaceObjectContents(target, nextValue) {
|
|
for (const key of Object.keys(target)) {
|
|
delete target[key];
|
|
}
|
|
Object.assign(target, nextValue);
|
|
return target;
|
|
}
|
|
|
|
export function inferRuntimeHome(statePath, metaPath) {
|
|
const stateDir = statePath ? path.dirname(statePath) : null;
|
|
const metaDir = metaPath ? path.dirname(metaPath) : null;
|
|
if (stateDir && metaDir && stateDir === metaDir) {
|
|
return stateDir;
|
|
}
|
|
return stateDir || metaDir || null;
|
|
}
|
|
|
|
export function isAuthRefreshRequiredError(error) {
|
|
const message = String(error?.message || "");
|
|
return [
|
|
"Refresh auth state first",
|
|
"Request redirected",
|
|
"Session redirected to Oracle sign-in",
|
|
"Oracle DV session restore redirected to Oracle sign-in",
|
|
"Request redirected while restoring Oracle DV session",
|
|
"Oracle DV session is not valid anymore",
|
|
"Could not find auth state",
|
|
"Could not find session metadata",
|
|
"Session metadata at",
|
|
"Expected JSON",
|
|
].some((fragment) => message.includes(fragment));
|
|
}
|