Logo

Disboardia

Fixing FlexiSCHED

Cover Image for Fixing FlexiSCHED
John Li
John Li
10 min read

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
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.

Flexisched Auth Flow
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!

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.

getMasterSchedule request
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!

Rendered 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?

dashboard.php request with 200 OK
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.

dashboard.php request with <title> highlighted
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.