|
| 1 | +// Submits audit runs to UT via authenticated same-origin fetches. Runs in a |
| 2 | +// content script on a UT page — the only context whose origin passes UT's |
| 3 | +// CSRF checks (extension-origin POSTs get 403). |
| 4 | +import type { CustomAuditRunRequest } from "@/domain/audit"; |
| 5 | +import { isLoginPage } from "@/features/session/session"; |
| 6 | +import { |
| 7 | + markAuditRunPending, |
| 8 | + RUN_AUDIT_BUTTON_SELECTOR, |
| 9 | +} from "./audit-history-sync"; |
| 10 | + |
| 11 | +const RUN_PAGE_URL = |
| 12 | + "https://utdirect.utexas.edu/apps/degree/audits/submissions/student_individual/"; |
| 13 | + |
| 14 | +// The individual-audit form; its selects populate only when the page is |
| 15 | +// fetched with catalog+college query parameters. |
| 16 | +const CUSTOM_FORM_SELECTOR = "#single_request"; |
| 17 | + |
| 18 | +// Runs the user's default profile audit, or a custom one when options are |
| 19 | +// given. Marks successful submissions pending so history polling finds them. |
| 20 | +export async function runAudit(custom?: CustomAuditRunRequest): Promise<void> { |
| 21 | + if (custom) { |
| 22 | + await submitCustomAudit(custom); |
| 23 | + } else { |
| 24 | + await submitDefaultAudit(); |
| 25 | + } |
| 26 | + await markAuditRunPending(); |
| 27 | +} |
| 28 | + |
| 29 | +async function submitDefaultAudit(): Promise<void> { |
| 30 | + const page = await fetchRunPage(); |
| 31 | + const form = page.querySelector(RUN_AUDIT_BUTTON_SELECTOR)?.closest("form"); |
| 32 | + if (!form) throw new Error("RUN_BUTTON_NOT_FOUND"); |
| 33 | + |
| 34 | + // Resolve the form's action against the fetched page, not the current one — |
| 35 | + // DOMParser documents inherit the creating page's base URL. |
| 36 | + const target = new URL(form.getAttribute("action") ?? "", RUN_PAGE_URL); |
| 37 | + await submitForm(form, target.toString()); |
| 38 | +} |
| 39 | + |
| 40 | +async function submitCustomAudit( |
| 41 | + options: CustomAuditRunRequest, |
| 42 | +): Promise<void> { |
| 43 | + const query = new URLSearchParams({ |
| 44 | + catalog: options.catalog, |
| 45 | + college: options.college, |
| 46 | + }); |
| 47 | + const page = await fetchRunPage(`?${query}`); |
| 48 | + const form = page.querySelector<HTMLFormElement>(CUSTOM_FORM_SELECTOR); |
| 49 | + if (!form) throw new Error("RUN_FORM_NOT_FOUND"); |
| 50 | + |
| 51 | + setSelect(form, "degree_plan", options.degreePlan); |
| 52 | + if (options.minor) setSelect(form, "minor", options.minor); |
| 53 | + if (options.certificate) setSelect(form, "certificate", options.certificate); |
| 54 | + setCheckbox(form, "current", options.includeCurrent ?? true); |
| 55 | + setCheckbox(form, "future", options.includeFuture ?? false); |
| 56 | + setCheckbox(form, "planned", options.includePlanned ?? false); |
| 57 | + |
| 58 | + // The form posts to its own parameterized URL (action=""). |
| 59 | + await submitForm(form, `${RUN_PAGE_URL}?${query}`); |
| 60 | +} |
| 61 | + |
| 62 | +async function fetchRunPage(search = ""): Promise<Document> { |
| 63 | + const response = await fetch(`${RUN_PAGE_URL}${search}`, { |
| 64 | + credentials: "include", |
| 65 | + }); |
| 66 | + if (!response.ok) throw new Error("RUN_FAILED"); |
| 67 | + |
| 68 | + const page = new DOMParser().parseFromString( |
| 69 | + await response.text(), |
| 70 | + "text/html", |
| 71 | + ); |
| 72 | + if (isLoginPage(page)) throw new Error("AUTH_REQUIRED"); |
| 73 | + return page; |
| 74 | +} |
| 75 | + |
| 76 | +async function submitForm( |
| 77 | + form: HTMLFormElement, |
| 78 | + targetUrl: string, |
| 79 | +): Promise<void> { |
| 80 | + const body = new URLSearchParams(); |
| 81 | + for (const [key, value] of new FormData(form)) { |
| 82 | + body.append(key, String(value)); |
| 83 | + } |
| 84 | + // FormData omits the submit control's pair, which UT's views expect |
| 85 | + // (e.g. audit="Submit Audit"). |
| 86 | + const submit = form.querySelector<HTMLInputElement | HTMLButtonElement>( |
| 87 | + '[type="submit"]', |
| 88 | + ); |
| 89 | + if (submit?.name) body.append(submit.name, submit.value); |
| 90 | + |
| 91 | + const response = await fetch(targetUrl, { |
| 92 | + method: "POST", |
| 93 | + credentials: "include", |
| 94 | + body, |
| 95 | + }); |
| 96 | + // A successful submission 302s to the request-history page. |
| 97 | + if (response.ok && response.redirected && response.url.includes("/history/")) |
| 98 | + return; |
| 99 | + |
| 100 | + const page = new DOMParser().parseFromString( |
| 101 | + await response.text(), |
| 102 | + "text/html", |
| 103 | + ); |
| 104 | + throw new Error(isLoginPage(page) ? "AUTH_REQUIRED" : "RUN_FAILED"); |
| 105 | +} |
| 106 | + |
| 107 | +function setSelect(form: HTMLFormElement, name: string, value: string): void { |
| 108 | + const select = form.querySelector<HTMLSelectElement>( |
| 109 | + `select[name="${name}"]`, |
| 110 | + ); |
| 111 | + if (!select) throw new Error("RUN_FORM_CHANGED"); |
| 112 | + if (![...select.options].some((option) => option.value === value)) { |
| 113 | + // e.g. a degree plan UT doesn't offer for this catalog+college |
| 114 | + throw new Error("OPTION_NOT_AVAILABLE"); |
| 115 | + } |
| 116 | + select.value = value; |
| 117 | +} |
| 118 | + |
| 119 | +function setCheckbox( |
| 120 | + form: HTMLFormElement, |
| 121 | + name: string, |
| 122 | + checked: boolean, |
| 123 | +): void { |
| 124 | + const box = form.querySelector<HTMLInputElement>(`input[name="${name}"]`); |
| 125 | + if (box) box.checked = checked; |
| 126 | +} |
0 commit comments