From 8168ff3cd4a50c7fe992e541805dd794ac8ee6d5 Mon Sep 17 00:00:00 2001 From: mnjx Date: Fri, 23 Jun 2023 20:18:17 +0200 Subject: [PATCH] Fri Jun 23 16:28:43 CEST 2023 --- Makefile | 2 +- README.md | 6 +- src/lib/crypto.py | 76 +++++++++++++++ src/lib/grades.py | 189 +++++++++++++++++++++--------------- src/lib/networking.py | 47 ++++++++- src/lib/schedule_tdy_tmr.py | 52 +++++++++- src/main.py | 72 ++++++++------ 7 files changed, 324 insertions(+), 120 deletions(-) create mode 100644 src/lib/crypto.py diff --git a/Makefile b/Makefile index 6fe5d49..a96cb0c 100755 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL=/bin/bash install: - python3 -m pip install -r requirements.txt --break-system-packages + python3 -m pip install -r requirements.txt ./.config/mkdirs.sh diff --git a/README.md b/README.md index 83a2ba5..023100b 100755 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ SSH: `git clone git@github.com:mnjx/solen.git` # Usage -Upon startup, the user os prompted to enter their ŠkolaOnLine credentials. -If the login proccess is successfull, the preconfigured functions in `/bin/solen` +Upon startup, the user is prompted to enter their ŠkolaOnLine credentials. +If the login proccess is successful, the preconfigured functions in `/bin/solen` will execute. By default, the functions are: showing the schedule for this week, showing the schedule for today and tomorrow and showing the grades. You can edit that file if you want to change what is being displayed. Alternatively, you can @@ -57,4 +57,4 @@ Here they are: | -p, --password | provide the password for login | | -l, --local | only show the local data without attempting to connect to the internet | | -d, --delete-local-files | deletes all locally stored user data, including schedules, grades and login credentials | -| -b, --backup PASSPHRASE DESTINATION | securly backs up all local user data into a .tar.gz archive | +| -b, --backup PASSPHRASE DESTINATION | backs up all local user data into a .tar.gz archive | diff --git a/src/lib/crypto.py b/src/lib/crypto.py new file mode 100644 index 0000000..3ed87d5 --- /dev/null +++ b/src/lib/crypto.py @@ -0,0 +1,76 @@ +""" + Cryptography related stuff - mostly + encrypting and decrypting user data. +""" + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.fernet import Fernet +import base64 +import os + +def get_key(password: str) -> bytes: + """ + Derives a bytes key + from a given password. + + Useful when you want + to generate the same + key over and over again. + E.g. to encrypt/decrypt + locally stored user data. + """ + + password = password.encode() + + salt = b'?\x0f\xa1\x97otT\xe48\xc9O\xb4\x1f\xef\xf5\xea' + + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256, + length=32, + salt=salt, + iterations=100000, + backend=default_backend() + ) + + key = base64.urlsafe_b64encode(kdf.derive(password)) + return key + +def encrypt_and_write_to_file(password: str, strdata: str, file_path: str) -> bool: + """ + Encrypts the given string data + and stores them in a file. + """ + + try: + key = get_key(password) + + f = Fernet(key) + + encrypted = f.encrypt(strdata.encode()) + + with open(file_path, "wb") as ef: + ef.write(encrypted) + + return 0 + except: + return 1 + +def read_from_file_and_decrypt(password: str, file_path: str) -> str: + """ + Reads contents of an encrypted file + and decrypts them. + """ + + try: + key = get_key(password) + + f = Fernet(key) + + with open(file_path, "rb") as ef: + decrypted = f.decrypt(ef.read()) + + return decrypted.decode() + except: + return "" diff --git a/src/lib/grades.py b/src/lib/grades.py index 2a4763f..fd6b160 100755 --- a/src/lib/grades.py +++ b/src/lib/grades.py @@ -1,39 +1,38 @@ +""" + Functions for grades related stuff. + Like 1, 2, 3, 4 and 5, in what subject, + what is the average of a subject, ... +""" + from solenlib import networking as nw +from solenlib import crypto as cr +from cryptography.fernet import Fernet from bs4 import BeautifulSoup import os not_login = 0 -def fetch_locally() -> list: +def fetch_locally(password, username) -> list: + """ + Helper function for exctracting + data from local files. + """ + global not_login if not_login == 1: not_login = 0 #print("Could not sign in (you may have entered invalid login credentials)") #print("Fetching grades locally\n") - grades = grades_from_file() + grades = grades_from_file(password, username) return grades +def get_grades(password, url: str) -> list: + """ + Extracts the grades from the HTML soup + and returns them in a list of dictionaries. + """ -""" -# deprecated -def get_avg(grades: list) -> str: - if len(grades) < 1: - return "" - - sum_ = 0 - for i in grades: - sum_ += int(i) - avg = round(sum_ / len(grades), 2) - - avg = str(avg) - if len(avg) == 3: - avg += "0" - - return avg -""" - -def get_grades(url: str): try: html = nw.get_html_from(url) @@ -51,15 +50,21 @@ def get_grades(url: str): subject = rows[i].find("td").text temp_dict = {} temp_dict["SUBJECT"] = subject - cell = rows[i].find_all("span", class_="VHVysl") + cells = rows[i].find_all("span", class_="VHVysl") temp = [] - for j in range(len(cell)): - temp.append(cell[j].text) - temp_dict["GRADES"] = temp + for j in range(len(cells)): + if cells[j].parent.has_attr("class"): + if "VHUzaverka" not in cells[j].parent["class"]: + temp.append(cells[j].text) + else: + temp.append(cells[j].text) + temp_dict["FINAL"] = rows[i].find("td", class_="VHUzaverka CCATooltip").text.replace(" ", "") - avg = rows[i].find("td", class_="VHCelkPrumer").text + avg = rows[i].find("td", class_="VHCelkPrumer").text.replace(",", ".") temp_dict["AVG"] = avg + temp_dict["GRADES"] = temp + grades.append(temp_dict) return grades @@ -67,18 +72,23 @@ def get_grades(url: str): global not_login not_login = 1 - grades = fetch_locally() + grades = fetch_locally(password, username) return grades -def show_grades(local=False): +def show_grades(password, username, local=False): + """ + Shows the grades in the terminal + in a pretty way. + """ + if local == False: if nw.is_connected() == True: - grades = get_grades("/SOL/App/Hodnoceni/KZH001_HodnVypisStud.aspx") - grades_to_file(grades) + grades = get_grades(password, "/SOL/App/Hodnoceni/KZH001_HodnVypisStud.aspx") + grades_to_file(password, username, grades) else: - grades = grades_from_file() + grades = grades_from_file(password, username) else: - grades = grades_from_file() + grades = grades_from_file(password, username) whitespace = "" max_len_of_subject = 0 @@ -98,73 +108,100 @@ def show_grades(local=False): print("N/A\n") import os - if os.path.exists(f"{os.environ['HOME']}/.local/lib/solendata/grades.csv"): - os.remove(f"{os.environ['HOME']}/.local/lib/solendata/grades.csv") + if os.path.exists(f"{os.environ['HOME']}/.local/lib/solendata/{nw.get_user_hash(username)}-encrypted_grades"): + os.remove(f"{os.environ['HOME']}/.local/lib/solendata/{nw.get_user_hash(username)}-encrypted_grades") return for i in range(len(grades)): if grades[i]["GRADES"] != []: whitespace = " " * (max_len_of_subject - len(grades[i]["SUBJECT"])) print(grades[i]["SUBJECT"] + whitespace + "|", end=" ") + print(grades[i]["FINAL"], "|", end=" ") print(grades[i]["AVG"], "|", end=" ") - print("".join([grades[i]["GRADES"][j]+" " for j in range(len(grades[i]["GRADES"]))]), end=" ") - print("") + print("".join([grades[i]["GRADES"][j]+" " for j in range(len(grades[i]["GRADES"]))])) + else: + if grades[i]["FINAL"] is not None: + whitespace = " " * (max_len_of_subject - len(grades[i]["SUBJECT"])) + print(grades[i]["SUBJECT"] + whitespace + "|", end=" ") + print(grades[i]["FINAL"], "|") + +def grades_to_file(password, username, grades=None): + """ + Writes the grades to a local file + for later use and extraction. + """ -def grades_to_file(grades=None): # !!! make sure the format of the grades list is saved correctly !!! import csv if grades is None: try: - grades = get_grades("/SOL/App/Hodnoceni/KZH001_HodnVypisStud.aspx") + grades = get_grades(password, username, "/SOL/App/Hodnoceni/KZH001_HodnVypisStud.aspx") except Exception: print("An error occured, you probably can't access the internet right now") return data = [] + lstrdata = [] - with open(f"{os.environ['HOME']}/.local/lib/solendata/grades.csv", mode="w", newline="") as f: - for i in range(len(grades)): - temp = {} - temp["SUBJECT"] = grades[i]["SUBJECT"] - temp["AVG"] = grades[i]["AVG"] - temp_grades = [] - for j in range(len(grades[i]["GRADES"])): - temp_grades.append(grades[i]["GRADES"][j]) - temp["GRADES"] = temp_grades - data.append(temp) + for i in range(len(grades)): + temp = {} + temp["SUBJECT"] = grades[i]["SUBJECT"] + temp["FINAL"] = grades[i]["FINAL"] + temp["AVG"] = grades[i]["AVG"] + temp_grades = [] + for j in range(len(grades[i]["GRADES"])): + temp_grades.append(grades[i]["GRADES"][j]) + temp["GRADES"] = temp_grades + data.append(temp) - writer = csv.writer(f) - for row in data: - _row = [] - _row.append(row["SUBJECT"]) - _row.append(row["AVG"]) - for i in row["GRADES"]: - _row.append(i) - writer.writerow(_row) + for row in data: + lstrdata.append(row["SUBJECT"]) + lstrdata.append(",") + lstrdata.append(row["FINAL"]) + lstrdata.append(",") + lstrdata.append(row["AVG"]) + lstrdata.append(",") + for i in row["GRADES"]: + lstrdata.append(i) + lstrdata.append(",") + del lstrdata[-1] + lstrdata.append("\n") -def grades_from_file() -> list: + strdata = "".join(lstrdata) + + cr.encrypt_and_write_to_file(password, strdata, f"{os.environ['HOME']}/.local/lib/solendata/{nw.get_user_hash(username)}-encrypted_grades") + +def grades_from_file(password, username) -> list: + """ + Extracts the grades from the local + file and returns them in a better + format. + """ import csv - grades = [] - + from io import StringIO try: - with open(f"{os.environ['HOME']}/.local/lib/solendata/grades.csv", mode="r") as f: - reader = csv.reader(f) - rows = [] - for row in reader: - rows.append(row) + strdata = cr.read_from_file_and_decrypt(password, f"{os.environ['HOME']}/.local/lib/solendata/{nw.get_user_hash(username)}-encrypted_grades") + except: + print("Could not read the file") + return + f = StringIO(strdata) + reader = csv.reader(f, delimiter=",") + grades = [] + rows = [] + for row in reader: + rows.append(row) - for row in range(len(rows)): - temp = {} + for row in range(len(rows)): + temp = {} - temp["SUBJECT"] = rows[row][0] - temp["AVG"] = rows[row][1] - - temp_lst = [] - for i in range(2, len(rows[row])): - temp_lst.append(rows[row][i]) - temp["GRADES"] = temp_lst + temp["SUBJECT"] = rows[row][0] + temp["FINAL"] = rows[row][1] + temp["AVG"] = rows[row][2] + + temp_lst = [] + for i in range(3, len(rows[row])): + temp_lst.append(rows[row][i]) + temp["GRADES"] = temp_lst - grades.append(temp) - except FileNotFoundError: - print("No grades.csv file found\n") + grades.append(temp) return grades diff --git a/src/lib/networking.py b/src/lib/networking.py index 810e793..f46fc3b 100755 --- a/src/lib/networking.py +++ b/src/lib/networking.py @@ -1,3 +1,7 @@ +""" + Functions for network related stuff. +""" + import requests import hashlib from bs4 import BeautifulSoup @@ -8,6 +12,11 @@ F_not_anymore = 0 session = requests.Session() def _read_shadow() -> str: + """ + Reads the shadow file and returns it in + a suitable format for other functions. + """ + result = "" with open(f"{os.environ['HOME']}/.local/lib/solendata/shadow", "r") as f: for row in f: @@ -16,6 +25,11 @@ def _read_shadow() -> str: return result def _save_creds(user: str, pwd: str): + """ + Saves credentials into the shadow file + by appending them. + """ + creds = _get_creds_hash(user, pwd) try: @@ -26,6 +40,11 @@ def _save_creds(user: str, pwd: str): print("No shadow file found") def _eval_creds(creds: str) -> bool: + """ + Evaluates the credentials for if + they are in the shadow file or not. + """ + shadow = _read_shadow() if creds in shadow: @@ -34,13 +53,26 @@ def _eval_creds(creds: str) -> bool: return False def _get_creds_hash(user: str, pwd: str) -> str: + """ + Creates a sha512 hash from the username + and password. + """ + user_hash = hashlib.sha512(bytes(user, 'utf-8')).hexdigest() pwd_hash = hashlib.sha512(bytes(pwd, 'utf-8')).hexdigest() creds = user_hash+":"+pwd_hash return creds +def get_user_hash(user: str) -> str: + return str(hashlib.sha512(bytes(user, 'utf-8')).hexdigest()) + def login(user: str, pwd: str) -> bool: + """ + Tries to send a request to the SOL + servers in order to log in. + """ + if is_connected(): url: str = "https://aplikace.skolaonline.cz/SOL/Prihlaseni.aspx" data = { @@ -61,21 +93,21 @@ def login(user: str, pwd: str) -> bool: post_login = session.post(url, data=data) if post_login.status_code != 200: - #print("Could not sign in") return False else: - #print("Sign in successful") return True else: - #print("Could not sign in") - #print("Fetching data from local files") - if _eval_creds(_get_creds_hash(user, pwd)): return True else: return False def is_connected() -> bool: + """ + Checks whether there is internet + access present. + """ + import socket try: @@ -88,6 +120,11 @@ def is_connected() -> bool: return False def get_html_from(params=None) -> str: + """ + Fetches the HTML markup of + a specified subsite of SOL. + """ + base = "https://aplikace.skolaonline.cz" if params is not None: diff --git a/src/lib/schedule_tdy_tmr.py b/src/lib/schedule_tdy_tmr.py index f6943b9..f242d7d 100755 --- a/src/lib/schedule_tdy_tmr.py +++ b/src/lib/schedule_tdy_tmr.py @@ -1,3 +1,8 @@ +""" + Functions related to today's + and tomorrow's schedule. +""" + from solenlib import networking as nw from bs4 import BeautifulSoup import os @@ -5,16 +10,25 @@ import os not_login = 0 def fetch_locally() -> list: + """ + Helper function for exctracting + data from a local file. + """ + global not_login if not_login == 1: not_login = 0 - #print("Could not sign in (you may have entered invalid login credentials)") - #print("Fetching the schedule for this week locally\n") + schedule = schedule_from_file() return schedule -def get_schedule(url: str): +def get_schedule(url: str) -> list: + """ + Filters & extracts the schedule from + the HTML soup. + """ + try: html = nw.get_html_from(url) @@ -120,10 +134,16 @@ def get_schedule(url: str): global not_login not_login = 1 - schedule = fetch_locally() - return schedule + return fetch_locally() def schedule_from_file_to_list(): + """ + Extracts the schedule from + a local file and returns + it in a list, which is better + for the get_whitespace() function. + """ + schedule = [] schedule_ = [] @@ -161,6 +181,13 @@ def schedule_from_file_to_list(): return schedule def get_whitespace(key: str, idx=0) -> int: + """ + Helper function that calculates + how much whitespaces are needed + in order to display the table grid + correctly. + """ + schedule = schedule_from_file_to_list() max_len = 0 @@ -178,6 +205,11 @@ def get_whitespace(key: str, idx=0) -> int: return max_len def show_schedule(local=False): + """ + Shows the schedule on the + terminal in a pretty way. + """ + if local == False: # if there is internet access, load stuff from the web # if not, load the schedule.csv file @@ -228,6 +260,12 @@ def show_schedule(local=False): print("") def schedule_to_file(schedule=None): + """ + Writes the schedule to + a local file for later + use. + """ + import csv if schedule is None: try: @@ -269,6 +307,10 @@ def schedule_to_file(schedule=None): writer.writerow(row) def schedule_from_file() -> list: + """ + + """ + import csv schedule = [] diff --git a/src/main.py b/src/main.py index d0921bf..db62272 100755 --- a/src/main.py +++ b/src/main.py @@ -7,7 +7,8 @@ from solenlib import schedule_tdy_tmr as st import sys import getpass import argparse -import datetime +import os +from datetime import datetime if __name__ == "__main__": parser = argparse.ArgumentParser(prog="ŠOLEn", description="ŠOLEn, an enhanced version of the ŠkolaOnLine online school system") @@ -20,9 +21,7 @@ if __name__ == "__main__": parser.add_argument("-l", "--local", action="store_true", help="Only fetch local files, don't connect to the internet") parser.add_argument("-d", "--delete-local-files", action="store_true", help="Delete all local user data, including schedules, grades and user credentials") - parser.add_argument("-b", "--backup", action="store_true", help="Securely back up all user data and store them in a .tar.gz archive in the destination directory") - parser.add_argument("-D", "--backup-directory", action="store", help="Backup destination directory") - parser.add_argument("-P", "--backup-password", action="store", help="Backup password to encrypt the archive with") + parser.add_argument("-b", "--backup", action="store_true", help="Back up all user data and store them in a .tar.gz archive in the destination directory") parser.add_argument("-R", "--backup-restore", action="store", nargs=1, metavar="PATH", help="Specify the path to a .tar.gz archive that was earlier created with ŠOLEn to restore backed up user data") args = parser.parse_args() @@ -30,37 +29,51 @@ if __name__ == "__main__": if args.delete_local_files: try: print("Removing all schedules and grades ...", end=" ") - os.system(f"rm -rf {os.environ['HOME']}/.local/lib/solendata/*.csv") + os.system(f"rm -rf {os.environ['HOME']}/.local/lib/solendata/*") print("done") print("Removing all user credentials ...", end=" ") os.system(f"rm -rf {os.environ['HOME']}/.local/lib/solendata/shadow") print("done") print("All user data removed successfully") - sys.exit(0) except: print("Could not remove user data") - sys.exit(1) elif args.backup: try: - if not args.backup_directory or not args.backup_password: - print("-D, --backup-directory BACKUP-DIRECTORY and -P, --backup-password BACKUP-PASSWORD are required") - sys.exit(1) - else: - nowtime = str(datetime.now().strftime("%Y-%m-%d_%H-%M-%S")) - try: - print("Creating the backup archive ...", end=" ") - os.system(f"tar cf solen-backup-{nowtime} {os.path.abspath(args.backup-directory)}") - print("done") - print("Backup archive created successfully") - sys.exit(0) - except: - print("The 'tar' utility is not installed") - sys.exit(1) + nowtime = str(datetime.now().strftime("%Y-%m-%d_%H-%M-%S")) + try: + print("Creating the backup archive ...", end=" ") + os.system(f"tar cf solen-backup-{nowtime}.tar {os.getcwd()}") + print("done") + print("Compressing the archive ...", end=" ") + os.system(f"gzip {os.getcwd()}/solen-backup-{nowtime}.tar") + print("done") + print("Backup archive created successfully") + except: + print("The 'tar' utility or the 'gzip' utility is not installed") except: print("Could not backup user data") - sys.exit(1) - + elif args.backup_restore: + try: + backup_path = args.backup_restore + print("Preparing ...", end=" ") + os.system(f"mkdir {os.getcwd()}/.solentmp") + print("done") + print("Uncompressing the archive ...", end=" ") + os.system(f"gunzip {backup_path}") + print("done") + print("Unpacking the archive ...", end=" ") + os.system(f"tar xzjf {backup_path} {os.getcwd()}/.solentmp") + print("done") + print("Moving the files ...", end=" ") + os.system(f"mv {os.getcwd()}/.solentmp/* {os.environ['HOME']}/.local/lib/solendata") + print("done") + print("Cleaning up ...", end=" ") + os.system(f"rm -rf {os.getcwd()}/.solentmp") + print("done") + print("Backup restored successfully") + except: + print("Could not restore the backup") else: if args.username: user = str(args.username) @@ -76,7 +89,7 @@ if __name__ == "__main__": pwd = str(input("Zadejte heslo: ")) if not nw.login(user, pwd): - print("Login unsuccessfull") + print("Login unsuccessful") sys.exit(1) else: try: @@ -92,20 +105,19 @@ if __name__ == "__main__": st.show_schedule() if args.grades: if args.local: - gr.show_grades(local=True) + gr.show_grades(pwd, user, local=True) else: - gr.show_grades() + gr.show_grades(pwd, user) if not args.grades and not args.today_tomorrow and not args.week: if args.local: sw.show_schedule(local=True) st.show_schedule(local=True) - gr.show_grades(local=True) + gr.show_grades(pwd, user, local=True) else: sw.show_schedule() st.show_schedule() - gr.show_grades() - sys.exit(0) + gr.show_grades(pwd, user) except Exception as e: + raise e print("Could not execute") - sys.exit(1)