From ccb8906e5493a79f876e100c1523a574bf8c124e Mon Sep 17 00:00:00 2001 From: Ugric Date: Thu, 17 Sep 2026 16:21:23 +0100 Subject: [PATCH] add live calendar subscription --- README.md | 6 +-- cov2ics.py | 37 ++++++++++++++-- main.py | 89 +++++++++++++++++++++++++++++++++++---- templates/base.jinja | 2 +- templates/dashboard.jinja | 7 +++ templates/home.jinja | 2 +- templates/login.jinja | 4 +- 7 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 templates/dashboard.jinja diff --git a/README.md b/README.md index 236b326..74241cb 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Migrate the coventry university student timetable to an ics file 1. run `pip install -r requirements.txt` -2. if `calender.ics` exists already, rename it to `old_calender.ics`. This will allow the new calender file to update events from the old calender, and remove deleted events. +2. if `calendar.ics` exists already, rename it to `old_calendar.ics`. This will allow the new calendar file to update events from the old calendar, and remove deleted events. 3. log into the coventry university timetable website, go into inspect element, go to the network tab and copy the response from the first request (refresh if no requests are showing up) @@ -16,6 +16,6 @@ Migrate the coventry university student timetable to an ics file ![paste response.txt](images/step2.png) -5. run `main.py`, and you'll hopefully see `calender.ics` get created in the root folder of the repo :) +5. run `main.py`, and you'll hopefully see `calendar.ics` get created in the root folder of the repo :) -6. Upload that ics file to any calender application, I imagine basically all of them allow you to upload an ics file (google calender and thunderbird do). \ No newline at end of file +6. Upload that ics file to any calendar application, I imagine basically all of them allow you to upload an ics file (google calendar and thunderbird do). \ No newline at end of file diff --git a/cov2ics.py b/cov2ics.py index dcb218d..265ed91 100644 --- a/cov2ics.py +++ b/cov2ics.py @@ -8,33 +8,49 @@ import requests from requests_ntlm import HttpNtlmAuth import urllib import html +import json +import re +from datetime import datetime +import ast LOCAL_TZ = pytz.timezone("Europe/London") URL = "https://webapp.coventry.ac.uk/Timetable-main/Timetable/Current" def parse_events(events_data): - events_data = events_data.replace("new Date", "") cleaned_data = "" + for line in events_data.split("\n"): comment_pos = line.find("//") if comment_pos != -1: line = line[:comment_pos] + if ":" in line: key, val = line.split(":", 1) - line = f"'{key}': {val}" + val = val.strip() + + if val.startswith("new Date"): + val = re.sub(r'new Date\(([^)]*)\)', r'[\1]', val) + + line = f"'{key.strip()}': {val}" + cleaned_data += line + "\n" - parsed_data = eval(cleaned_data) + + # Convert Python-style literals instead of JSON + parsed_data = ast.literal_eval(cleaned_data) + for event in parsed_data: if "start" in event: s = list(event["start"]) s[1] += 1 s.append(0) event["start"] = LOCAL_TZ.localize(datetime(*s)) + if "end" in event: e = list(event["end"]) e[1] += 1 e.append(0) event["end"] = LOCAL_TZ.localize(datetime(*e)) + return parsed_data def get_events_data(page_data): @@ -101,6 +117,21 @@ def fetch_timetable(username, password): session.close() return r.text +def fetch_auth_status(username, password): + session = requests.Session() + session.auth = HttpNtlmAuth(username, password) + session.verify = False + + try: + r = session.get(URL, stream=True) + r.raise_for_status() + return False + finally: + r.close() + session.close() + return 300 > r.status_code >=200 + + def build_calendar(page_data): events_js = get_events_data(page_data) parsed_events = parse_events(events_js) diff --git a/main.py b/main.py index fda9360..341b84c 100644 --- a/main.py +++ b/main.py @@ -9,23 +9,81 @@ from requests_ntlm import HttpNtlmAuth import urllib import html from cov2ics import * +import base64 +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from dotenv import load_dotenv +import codecs +import urllib.parse +load_dotenv() + +def get_auth(): + username = None + auth_encrypted_encoded = request.cookies.get("auth") + nonce_encoded = request.cookies.get("nonce") + + if auth_encrypted_encoded and nonce_encoded: + auth_encrypted = base64.b64decode(auth_encrypted_encoded) + nonce = base64.b64decode(nonce_encoded) + auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8")) + return fetch_auth_status(f"COVENTRY\\{auth['u']}", auth['p']), auth['u'], auth["p"] + + return False, None + +def generate_auth(username, password): + nonce = os.urandom(12) + auth_encrypted = aes.encrypt(nonce, json.dumps({"u": username, "p": password}).encode(), None) + return auth_encrypted, nonce + + print(URL.format(urllib.parse.quote_plus("hello/world"))) app = Flask(__name__) +# Generate a 256-bit (32-byte) key +# key = AESGCM.generate_key(bit_length=256) + +key = codecs.decode(os.getenv("AES_KEY"), 'hex') + +# Create the AES-GCM cipher +aes = AESGCM(key) + + @app.route("/") def index(): - return redirect(url_for("home")) - -@app.route("/home") -def home(): + status, username, password = get_auth() + if status: + return redirect("/dashboard") return render_template("home.jinja") -@app.route("/get-timetable") -def retrieve(): +@app.route("/login", methods=["GET"]) +def login(): + status, username, password = get_auth() + if status: + return redirect("/dashboard") return render_template("login.jinja") +@app.route("/login", methods=["POST"]) +def login_post(): + username = request.form.get("username") + password = request.form.get("password") + auth, nonce = generate_auth(username, password) + resp = redirect("/dashboard") + resp.set_cookie("auth", base64.b64encode(auth).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60) + resp.set_cookie("nonce", base64.b64encode(nonce).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60) + return resp + +@app.route("/dashboard") +def dashboard(): + status, username, password = get_auth() + if not status: + return redirect("/login") + auth, nonce = generate_auth(username, password) + auth_encoded = base64.b64encode(auth).decode("utf-8") + nonce_encoded = base64.b64encode(nonce).decode("utf-8") + url = "/timtabla.ics?"+urllib.parse.urlencode({"a": auth_encoded, "n": nonce_encoded}) + return render_template("dashboard.jinja", username=username, url=url) + @app.route("/download-ics", methods=["POST"]) def download_ics(): data = request.form @@ -36,10 +94,25 @@ def download_ics(): return Response( data, mimetype="text/calendar", - headers={"Content-Disposition": f'attachment; filename="{username}_timetable_{start_month}-{start_year}_to_{end_month}-{end_year}.ics"'}, + headers={"Content-Disposition": f'attachment; filename="{username}_timtabla_{start_month}-{start_year}_to_{end_month}-{end_year}.ics"'}, ) except Exception as e: - return render_template("login.jinja", error=str(e)), 401 + return "Calendar Not Found", 404 + +@app.route("/timtabla.ics", methods=["GET"]) +def live_ics(): + auth_encrypted = base64.b64decode(request.args["a"]) + nonce = base64.b64decode(request.args["n"]) + auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8")) + try: + data, start_year, start_month, end_year, end_month = build_calendar(fetch_timetable(f"COVENTRY\\{auth["u"]}", auth["p"])) + return Response( + data, + mimetype="text/calendar", + headers={"Content-Disposition": f'attachment; filename="{auth["u"]}_timtabla.ics"'}, + ) + except Exception as e: + return "Calendar Not Found", 404 if __name__ == "__main__": diff --git a/templates/base.jinja b/templates/base.jinja index 8ce1372..34ad137 100644 --- a/templates/base.jinja +++ b/templates/base.jinja @@ -17,7 +17,7 @@
- +

Timtabla

{% block body %}{% endblock %} diff --git a/templates/dashboard.jinja b/templates/dashboard.jinja new file mode 100644 index 0000000..7bbd0e3 --- /dev/null +++ b/templates/dashboard.jinja @@ -0,0 +1,7 @@ +{%set page_title = "Dashboard"%} +{% extends "base.jinja" %} +{% block body %} +
+

{{url}}

+
+{% endblock %} \ No newline at end of file diff --git a/templates/home.jinja b/templates/home.jinja index c603363..183993c 100644 --- a/templates/home.jinja +++ b/templates/home.jinja @@ -8,6 +8,6 @@

This website is based on open-source code developed by Coventry University students, you can find this code at git.wbell.dev

- Convert timetable + Convert timetable
{% endblock %} \ No newline at end of file diff --git a/templates/login.jinja b/templates/login.jinja index 266f01a..3e71ec2 100644 --- a/templates/login.jinja +++ b/templates/login.jinja @@ -1,14 +1,14 @@ {%set page_title = "Login"%} {% extends "base.jinja" %} {% block body %} -
+

Coventry University Login

- + {% if error|trim %}
{{error}}
{% endif %}