Fixing FlexiSCHED
Background
Ah yes. Another one of my school's terrible technological choices. Going to one of the best if the best high schools in California (Thanks Niche), you would imagine the technology used for school would be somewhat half decent right? Well nope! Not even close.
Well, here's some context:
Because our school loves making us go through like 20 hoops for a really simple thing like scheduling office hours (yay bureaucracy), we get a lovely little app the school allegedly paid around $3,000/year for. Honestly, a massive rip off especially for a UI that looks like this:
Flexisched UI |
Well, aside from the glaringly bad UI (Seriously, I've seen beginners with better UI than this), the entire system flow to just schedule a PRIME (Our school's version of office hours) just sucks.
My (very nice) graphic of how auth works with FlexiSCHED |
Suffice to say, its not very user friendly now, is it?
Problems and Solutions
Ok, so to create a solution, we first need to identify the problems.
Problems to solve
- Incredibly Bad UI
- Long process to schedule a class
- Too much cluttered text / options
- Hard to read
- Process must be repeated for every session
It seems pretty clear that we can just simply fix this with just putting the FlexiSched functionality into a simple extension
Building The Extension
1. Setting Up the Scaffolding
Ok, so first off, as a massive React simp, we are probably going to use a React Chrome Extension Boilerplate (Thank you sivertschou for the boilerplate).
Now, to speed up UI designing, I'll use TailwindCSS so I can get a basic UI fleshed out. I'll skip over setting up TailwindCSS as there are many other tutorials online. By the end of the setup, your files should look something like this:
package.json
{ "name": "chrome-extension-react-typescript-boilerplate", "version": "1.0.0", "description": "", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "export NODE_ENV=production && webpack", "start": "webpack --watch", "dev": "react-scripts start" }, "keywords": [], "license": "ISC", "devDependencies": { "@babel/core": "^7.12.3", "@babel/preset-env": "^7.12.1", "@babel/preset-react": "^7.12.1", "@headlessui/react": "^1.7.3", "@hot-loader/react-dom": "^17.0.0-rc.2", "@types/chrome": "^0.0.199", "@types/react": "^16.9.53", "@types/react-dom": "^16.9.8", "autoprefixer": "^10.4.12", "babel-loader": "^8.1.0", "copy-webpack-plugin": "^6.2.1", "css-loader": "^5.0.0", "file-loader": "^6.1.1", "postcss": "^8.4.18", "postcss-loader": "^7.0.1", "postcss-preset-env": "^7.8.2", "react-scripts": "^5.0.1", "style-loader": "^2.0.0", "tailwindcss": "^3.2.0", "ts-loader": "^8.0.5", "typescript": "^4.0.3", "url-loader": "^4.1.1", "webpack": "^5.1.3", "webpack-cli": "^4.0.0", "webpack-dev-server": "^3.11.0" }, "dependencies": { "@heroicons/react": "^2.0.13", "cheerio": "^1.0.0-rc.12", "events": "^3.3.0", "fuse.js": "^6.6.2", "react": "^16.14.0", "react-dom": "^16.14.0", "react-hot-loader": "^4.13.0", "tailwind-scrollbar-hide": "^1.1.7" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } }
postcss.config.js
const tailwindcss = require('tailwindcss'); module.exports = { plugins: [ 'postcss-preset-env', tailwindcss ], };
tailwind.config.js
/** @type {import('tailwindcss').Config} */ const colors = require('tailwindcss/colors'); module.exports = { mode: "jit", purge: [ "./src/**/*.{js,ts,jsx,tsx}", "./dist/**/*.{js,ts,jsx,tsx}", ], darkMode: "class", theme: { }, variants: { extend: {}, }, plugins: [ require('tailwind-scrollbar-hide'), ], };
Note for tailwind.config.js
, you really don't need everything inside theme
unless you're using it. I just have it there because I'm lazy :P
2. Reverse-Engineer the FlexiSCHED API.
Now, it's time to reverse-engineer the FlexiSCHED API. This is the hardest part of the process, and it's not even that hard. Of course, I will be using my good old friend, Dev Tools!
You can get this via right-clicking and clicking "Inspect Element" or pressing Ctrl + Shift + I and going to the Network Tab |
Checking for Fetch/XHR Requests
We need to figure out what is dynamically loaded and what is not. To do this, we need to check for Fetch/XHR requests. To do this, we need to go to the Network
tab and refresh the page. Then, we need to filter by XHR
or Fetch/XHR
and see what requests are made. Would you look at that! We have a request called getMasterSchedule.php
! Let's click on it and see what it does.
What we (the web client) is getting back from the request |
Well, it looks like we're getting back a JSON object, but most of the actually usable data is literally just HTML, and it seems to just be what offering we're enrolled in.
{"data":[["<small>Default: Blackburn, Kristy<\/small><hr><b>Live: Blackburn, Kristy <i class=\"fa fa-sign-in text-muted\" aria-hidden=\"true\" title=\"Scheduled to default.\" aria-label=\"Scheduled to default.\"><\/i><br>Room: P-115<br>This tutorial space is available for The Oracle\/The Olympian interviews (newspaper\/yearbook) any current or former students who are interested in doing college essay stuff, and current English students.<br>Scheduled to default.<br><\/b>","<div class=\"btn-group\" role=\"group\" ><button type=\"button\" class=\"btn btn-secondary btn-sm\" data-toggle=\"modal\" data-placement=\"top\" title=\"Email Schedule: John Li\" onclick=\"emailModal('95038768')\"><i class=\"fa fa-envelope-o\" aria-hidden=\"true\"><\/i><\/button><\/div><\/td><\/div>"]]}
This suggests that most of our data (all avalible classes) is statically generated, time to go check out dashboard.php
!
What we get from dashboard.php (Rendered because the raw HTML is literally just 1 very long line) |
Well, we found it... Just a mess of ugly HTML we're going to have to parse. luckily, we can use cheerio
to parse it for us!
import { Cheerio, load as cload } from "cheerio"; type ClassOption = { type: string; description: string; restrictions: string; category: string; teacher: { first?: string; last?: string; displayName?: string; raw: string; }; room?: string; limit?: number; open?: boolean; }; const rawhtml = await fetch(`${origin}/dashboard.php`).then((res) => res.text() ); const tabledata = cload(rawhtml, { withEndIndices: true, withStartIndices: true, }); const event = tabledata("#studentResults thead tr th") .eq(0) .html() ?.replace(/(<.+?>)/g, " "); console.log({ event, data: tabledata("#studentResults thead tr").html(), datachild: tabledata("#studentResults thead tr th") .eq(0) .html() ?.replace(/(<.+?>)/g, " "), }); this.currentEvent = event; // grab all rows from the table const rowdata = tabledata("#results tbody"); const classes = [] as ClassOption[]; const rdchildren = rowdata.children(); this.optionMap = this.optionMap || new Map(); for (let i = 0; i < rdchildren.length; i++) { const row = rdchildren.eq(i); const parsed = parseOption(row); this.optionMap?.set( parsed.teacher.raw, [parsed] ); classes.push(parsed); } return classes;
parseOption
function parseOption(opt: Cheerio<Element>) { const teacher = opt.children().eq(0).text(); const email = opt.children().eq(1).text(); //ignore; not needed const category = opt.children().eq(2).text(); const rawDesc = opt.children().eq(3).html(); // '<b>Type: Tutorial<br>Restriction: None<br>28 - PE Make Ups/Tutorial</b><br>' ; 'Restriction: None<br>25 - A quiet place to study or talk about Psych<br>' no definitive pattern, will likely have to regex; const type = rawDesc?.match(/Type: (.+?)(?=<br>)/)?.[1] ?? "Unknown"; let restrictedToRaw = rawDesc?.match(/Restriction: (.+?)(?=<br>)/)?.[1] ?? ""; const restrictedTo = restrictedToRaw.toLowerCase() === "none" ? "" : restrictedToRaw; const limdesc = rawDesc?.match(/(<br>)(\d+?)(?: - )(.+?)(?:<\/b>|<br>)/) ?? new Array(5).fill(null); const limit = Number.isInteger(parseInt(limdesc[2])) ? ~~limdesc[2] : null; const description = limdesc[3] ?? "No description provided"; const teacherName = teacher?.includes(",") ? teacher.split(",").reverse() : teacher; let formattedTeacher = { displayName: teacher, ...(Array.isArray(teacherName) && { first: teacherName![0], last: teacherName![1], displayName: undefined, }), raw: teacher, }; return { type, description, restrictions: restrictedTo, category, teacher: formattedTeacher, room: undefined, limit, open: limit !== null && limit > 0, } as ClassOption; }
However, before we implement the actual code, there's one thing we need to deal with: Cookies
Getting the Authorization Cookie
We need to figure out how to actually get an authorization cookie. This won't be easy, luckily, extensions come with the webRequest
API, allowing us to intercept and modify requests. We know that HTTP-Only Cookies can only be set via the Set-Cookie
header, so now, what we need to do is find a Set-Cookie
header, read the cookie, and send into some sort of storage so we can use it later. A quick look at all requests to any .php
site shows us the cookie changes every request, however, it does not invalidate the previous cookie. This means we can just grab any cookie we see and use it.
chrome?.webRequest?.onHeadersReceived.addListener( (details) => { const cookies = details.responseHeaders?.find( (header) => header.name.toLowerCase() === "set-cookie" ); // read http-only cookie if (!cookies) return; const [name, value] = cookies?.value?.split(";")[0].split("=")!; // TODO: store/validate cookie }, { urls: ["https://*.flexisched.net/dashboard.php"] }, ["extraHeaders", "responseHeaders"] );
Validating the Cookie
Now that we have the cookie, we need to check if it's valid. We can do this by making a request to the /dashboard.php
endpoint, and checking if we get a valid response. Unfortunately, FlexiSched really ignores basic Response Statuses, so we cannot just check response.status
for a 200, because it seems to send a 200 OK
regardless of the validity of the Cookie. Worse off, FlexiSched literally just replaces the HTML of the page with a Login, so we would have to do more HTML parsing to determine if the page sent is of the actual dashboard or just a login page. Or do we?
Lovely, the dashboard.php page returns a 200 OK despite asking us to login. Request handling here... |
Luckily, upon delving deeper into this issue, we can see that the response actually contains a <title>
tag, and you know what that means...
We can actually just check if the title is <title>FlexiSCHED Login</title>
or not. If it is, we can assume that the cookie is invalid, and we ignore the request. If it isn't, we can assume that the cookie is valid, and we can use/store it.
HTML <title> tag coming in clutch!! |
const req2 = await fetch( `${getOrigin( url! )}/dashboard.php?norecurse=1&c=${nonce}&dc=${existingCookie}`, { headers: { Cookie: `flexisched_session_id=${cookieData.value}`, }, credentials: "include", } ).then((res) => res.text()); if (req2.includes("FlexiSCHED Login")) { // new cookie is invalid console.log("Invalid cookie, login required"); return; } // cookie is valid // TODO: Store Cookie!
But wait, something's wrong. Our cookie isn't being sent with the request. Why? Well, it turns out that the Cookie
header is a forbidden header, and cannot be set by the fetch
API. For this request, it does not matter because we will still check with the latest cookie, but it will for future requests. So, we're going to need some form of workaround. Although we could use the chrome.cookies API, I instead opted to use an external Express REST server because we also need to handle other features like cookie refresh.
Building the Backend
I won't go into too much detail, as the code is pretty open source. However, we need our server to handle the following:
- A
GET /
endpoint that returns the current cookie Code - A
POST /
endpoint that sets the cookie Code - An auto refresh system that refreshes the cookie within a certain interval Code
- An endpoint that forwards requests to FlexiSched, embedding the
Cookie
header in the request, and returning the response (Handled by FlexiReqPost.ts and FlexiReq.ts)
If you would like to see the entire server code, you can find it here.
Abrupt Conclusion
This post is already pretty long, yet it only covers about 1/4 of the total code. If you would like to take a look, feel free to here. Anyways, that will wrap up my first (and very scuffed) code blog post. God this took me like 4 hours, sitting at a McDonalds sipping inhuman amounts of Diet Coke.