229 lines
7 KiB
TypeScript
229 lines
7 KiB
TypeScript
|
|
// getPegasusPHPSession.ts
|
|||
|
|
import { secrets, sleep } from "bun";
|
|||
|
|
import puppeteer, { Browser, Page } from "puppeteer";
|
|||
|
|
import auth from "./auth.json";
|
|||
|
|
|
|||
|
|
// ——— Credentials from Bun secrets (unchanged) ———
|
|||
|
|
const email = auth.email || (await secrets.get({ service: "estia", name: "email" })) || "";
|
|||
|
|
const password = auth.password || (await secrets.get({ service: "estia", name: "password" })) || "";
|
|||
|
|
|
|||
|
|
// ——— Options ———
|
|||
|
|
export type GetSessionOpts = {
|
|||
|
|
headless?: true | false | "new";
|
|||
|
|
debugScreenshots?: boolean;
|
|||
|
|
timeoutMs?: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ——— Utility: screenshots only when debugging ———
|
|||
|
|
async function shot(page: Page, name: string, enabled: boolean) {
|
|||
|
|
if (!enabled) return;
|
|||
|
|
try {
|
|||
|
|
await page.screenshot({ path: `./${name}.png`, fullPage: true });
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ——— Utility: wait for either navigation or one of the selectors ———
|
|||
|
|
async function waitForNavOrSelector(
|
|||
|
|
page: Page,
|
|||
|
|
selectors: string[],
|
|||
|
|
timeout = 30_000
|
|||
|
|
): Promise<"navigated" | { selector: string }> {
|
|||
|
|
const selPromise = (async () => {
|
|||
|
|
const start = Date.now();
|
|||
|
|
while (Date.now() - start < timeout) {
|
|||
|
|
for (const sel of selectors) {
|
|||
|
|
const el = await page.$(sel);
|
|||
|
|
if (el) return { selector: sel as string };
|
|||
|
|
}
|
|||
|
|
await page.waitForTimeout(150);
|
|||
|
|
}
|
|||
|
|
throw new Error("timeout waiting for selectors");
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
const navPromise = page
|
|||
|
|
.waitForNavigation({ waitUntil: "domcontentloaded", timeout })
|
|||
|
|
.then(() => "navigated" as const);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
return await Promise.race([selPromise, navPromise]);
|
|||
|
|
} catch {
|
|||
|
|
// If both rejected (rare), try a small grace wait
|
|||
|
|
await page.waitForTimeout(300);
|
|||
|
|
return "navigated";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ——— Utility: resilient click that retries across navigations ———
|
|||
|
|
async function clickResilient(page: Page, selector: string, { timeout = 10_000 }: { timeout?: number } = {}) {
|
|||
|
|
const end = Date.now() + timeout;
|
|||
|
|
while (Date.now() < end) {
|
|||
|
|
try {
|
|||
|
|
await page.waitForSelector(selector, { visible: true, timeout: 1000 });
|
|||
|
|
await page.click(selector, { delay: 20 });
|
|||
|
|
return;
|
|||
|
|
} catch (err: any) {
|
|||
|
|
const msg = String(err?.message || "");
|
|||
|
|
// These are fine — we’ll retry until timeout
|
|||
|
|
if (
|
|||
|
|
msg.includes("Execution context was destroyed") ||
|
|||
|
|
msg.includes("Inspected target navigated or closed") ||
|
|||
|
|
msg.includes("Cannot find context with specified id")
|
|||
|
|
) {
|
|||
|
|
await page.waitForTimeout(150);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// If element isn't there yet, keep looping
|
|||
|
|
if (msg.includes("waiting for selector")) {
|
|||
|
|
await page.waitForTimeout(150);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// Unknown error — bubble up
|
|||
|
|
throw err;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
throw new Error(`Timeout clicking ${selector}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ——— Utility: wait until any page returns to the Pegasus host ———
|
|||
|
|
async function pickPegasusPage(browser: Browser, timeout = 30_000): Promise<Page> {
|
|||
|
|
const end = Date.now() + timeout;
|
|||
|
|
while (Date.now() < end) {
|
|||
|
|
const pages = await browser.pages();
|
|||
|
|
for (const p of pages) {
|
|||
|
|
const url = p.url();
|
|||
|
|
if (url.includes("learning.estia.fr")) return p;
|
|||
|
|
}
|
|||
|
|
await new Promise((r) => setTimeout(r, 300));
|
|||
|
|
}
|
|||
|
|
throw new Error("Timeout waiting to return to learning.estia.fr");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ——— Main ———
|
|||
|
|
export async function getPegasusPHPSession({
|
|||
|
|
headless = "new",
|
|||
|
|
debugScreenshots = false,
|
|||
|
|
timeoutMs = 60_000,
|
|||
|
|
}: GetSessionOpts = {}): Promise<string> {
|
|||
|
|
let browser: Browser | undefined;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
browser = await puppeteer.launch({
|
|||
|
|
headless,
|
|||
|
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const page = await browser.newPage();
|
|||
|
|
page.setDefaultTimeout(timeoutMs);
|
|||
|
|
|
|||
|
|
// 1) Open Pegasus
|
|||
|
|
await page.goto("https://learning.estia.fr/pegasus/index.php", {
|
|||
|
|
waitUntil: "networkidle2",
|
|||
|
|
});
|
|||
|
|
await shot(page, "01_pegasus_home", debugScreenshots);
|
|||
|
|
|
|||
|
|
// 2) Click Microsoft login (allow same-tab or new-tab)
|
|||
|
|
const msBtn = "a.authlink[href*='o365Auth.php']";
|
|||
|
|
await page.waitForSelector(msBtn, { visible: true });
|
|||
|
|
await page.click(msBtn);
|
|||
|
|
|
|||
|
|
// 2.1) If a new AAD tab opens, switch to it
|
|||
|
|
let authPage: Page = page;
|
|||
|
|
const newTarget = await browser
|
|||
|
|
.waitForTarget((t) => /login\.microsoftonline\.com/i.test(t.url()), {
|
|||
|
|
timeout: 10_000,
|
|||
|
|
})
|
|||
|
|
.catch(() => undefined);
|
|||
|
|
|
|||
|
|
if (newTarget) {
|
|||
|
|
const p = await newTarget.page();
|
|||
|
|
if (p) authPage = p;
|
|||
|
|
} else {
|
|||
|
|
// Same tab flow
|
|||
|
|
await page.waitForNavigation({ waitUntil: "domcontentloaded" }).catch(() => {});
|
|||
|
|
}
|
|||
|
|
await shot(authPage, "02_after_click_ms", debugScreenshots);
|
|||
|
|
|
|||
|
|
// 3) Email
|
|||
|
|
await authPage.waitForSelector('input[name="loginfmt"]', { visible: true });
|
|||
|
|
await authPage.type('input[name="loginfmt"]', email, { delay: 20 });
|
|||
|
|
await authPage.keyboard.press("Enter");
|
|||
|
|
|
|||
|
|
// (Optional) tenant account picker
|
|||
|
|
await authPage
|
|||
|
|
.waitForSelector("#i0118, div[role='button'][data-telemetryid='UserTile']", { timeout: 5000 })
|
|||
|
|
.catch(() => {});
|
|||
|
|
const tile = await authPage.$("div[role='button'][data-telemetryid='UserTile']");
|
|||
|
|
if (tile) {
|
|||
|
|
await clickResilient(authPage, "div[role='button'][data-telemetryid='UserTile']", {
|
|||
|
|
timeout: 5_000,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await sleep(2000); // Wait a bit for the transition
|
|||
|
|
|
|||
|
|
// 4) Password
|
|||
|
|
await authPage.waitForSelector("#i0118", { visible: true });
|
|||
|
|
await authPage.type("#i0118", password, { delay: 20 });
|
|||
|
|
await authPage.keyboard.press("Enter");
|
|||
|
|
await shot(authPage, "03_after_password", debugScreenshots);
|
|||
|
|
|
|||
|
|
await sleep(2000); // Wait a bit for the transition
|
|||
|
|
|
|||
|
|
// 5) KMSI (Stay signed in) — prefer "No", else "Yes".
|
|||
|
|
// We *don’t* run big evaluate() here; we click selectors resiliently.
|
|||
|
|
const kmsiCandidates = ["#idBtn_Back", "#idSIButton9"];
|
|||
|
|
const kmsiOutcome = await waitForNavOrSelector(authPage, kmsiCandidates, 20_000).catch(
|
|||
|
|
() => "navigated" as const
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (kmsiOutcome !== "navigated") {
|
|||
|
|
const toClick =
|
|||
|
|
kmsiOutcome.selector === "#idBtn_Back"
|
|||
|
|
? "#idBtn_Back"
|
|||
|
|
: (await authPage.$("#idBtn_Back"))
|
|||
|
|
? "#idBtn_Back"
|
|||
|
|
: "#idSIButton9";
|
|||
|
|
try {
|
|||
|
|
await clickResilient(authPage, toClick, { timeout: 8_000 });
|
|||
|
|
} catch {
|
|||
|
|
// If we miss it due to instant nav, that’s okay
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
await shot(authPage, "04_after_kmsi", debugScreenshots);
|
|||
|
|
|
|||
|
|
// 6) Back to Pegasus (could happen in any tab)
|
|||
|
|
let finalPage: Page;
|
|||
|
|
try {
|
|||
|
|
finalPage = await pickPegasusPage(browser, 30_000);
|
|||
|
|
} catch {
|
|||
|
|
// As a fallback, let the current authPage settle
|
|||
|
|
await authPage.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 10_000 }).catch(() => {});
|
|||
|
|
finalPage = authPage;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Network settle
|
|||
|
|
try {
|
|||
|
|
await finalPage.waitForNetworkIdle({ idleTime: 1000, timeout: 30_000 });
|
|||
|
|
} catch {}
|
|||
|
|
await shot(finalPage, "05_back_on_pegasus", debugScreenshots);
|
|||
|
|
|
|||
|
|
// 7) Cookies → PHPSESSID
|
|||
|
|
const cookies = await finalPage.cookies("https://learning.estia.fr");
|
|||
|
|
const php = cookies.find((c) => c.name.toLowerCase() === "phpsessid");
|
|||
|
|
if (!php?.value) {
|
|||
|
|
throw new Error("PHPSESSID cookie not found. Are you logged in?");
|
|||
|
|
}
|
|||
|
|
return php.value;
|
|||
|
|
} finally {
|
|||
|
|
if (browser) {
|
|||
|
|
try {
|
|||
|
|
await browser.close();
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const token = await getPegasusPHPSession({ headless: true, debugScreenshots: false });
|
|||
|
|
|
|||
|
|
export { token };
|