gmao/app_new/lib_ext/pronotepy/pronoteAPI.py

364 lines
14 KiB
Python
Raw Normal View History

2026-08-14 18:02:25 +02:00
from __future__ import annotations
import base64
import json as jsn
import re
from logging import getLogger
import secrets
import threading
import zlib
from time import time, sleep
from typing import Union, Optional, TYPE_CHECKING, Any, List, Tuple
import requests
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.Hash import MD5
from Crypto.PublicKey import RSA
from Crypto.Util import Padding
from bs4 import BeautifulSoup
from .exceptions import *
if TYPE_CHECKING:
from requests import Response
from requests.cookies import RequestsCookieJar
from .clients import ClientBase
log = getLogger(__name__)
error_messages = {
22: '[ERROR 22] The object was from a previous session. Please read the "Long Term Usage" section in README on '
"github.",
10: "[ERROR 10] Session has expired and pronotepy was not able to reinitialise the connection.",
25: "[ERROR 25] Exceeded max authorization requests. Please wait before retrying...",
}
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:73.0) Gecko/20100101 Firefox/73.0 PRONOTE Mobile APP",
}
class _Communication(object):
def __init__(self, site: str, cookies: Optional["RequestsCookieJar"]) -> None:
"""Handles all communication with the PRONOTE servers"""
self.root_site, self.html_page = self.get_root_address(site)
self.session = requests.Session()
self.session.headers.update(HEADERS)
self.encryption = _Encryption()
self.attributes: dict = {}
self.request_number = 1
self.cookies = cookies
self.last_ping = 0
self.authorized_onglets: List[int] = []
self.compress_requests = False
self.encrypt_requests = False
self.last_response: Response
def initialise(self, client_identifier: Optional[str] = None) -> Tuple[Any, Any]:
"""
Initialisation of the communication. Sets up the encryption and sends the IV for AES to PRONOTE.
From this point, everything is encrypted with the communicated IV.
"""
# get rsa keys and session id, retry 3 times
for _ in range(3):
try:
log.debug(f"Requesing html: {self.root_site}/{self.html_page}")
get_response = self.session.request(
"GET",
f"{self.root_site}/{self.html_page}",
cookies=self.cookies,
)
self.attributes = self._parse_html(get_response.content)
except ValueError:
log.warning(
"[_Communication.initialise] Failed to parse html, retrying..."
)
continue # retry
else:
break
else:
raise PronoteAPIError(
"Unable to connect to pronote, please try again later"
)
uuid = base64.b64encode(
self.encryption.rsa_encrypt(self.encryption.aes_iv_temp)
if self.attributes.get("http", False)
else self.encryption.aes_iv_temp
).decode()
# post
json_post = {"Uuid": uuid, "identifiantNav": client_identifier}
self.encrypt_requests = self.attributes.get("CrA", False)
self.compress_requests = self.attributes.get("CoA", False)
# we need to catch this exception. the iv was not yet set and we need to decrypt it with the correct iv.
initial_response = self.post(
"FonctionParametres",
{"data": json_post},
decryption_change={"iv": MD5.new(self.encryption.aes_iv_temp).digest()},
)
return self.attributes, initial_response
def post(
self, function_name: str, data: dict, decryption_change: Optional[dict] = None
) -> dict:
"""
Handler for all POST requests by the api to PRONOTE servers. Automatically provides all needed data for the
verification of posts. Session id and order numbers are preserved.
Args:
function_name (str): The name of the function (eg. Authentification)
data (dict): The date that will be sent in the dataSec dictionary
decryption_change (Optional[dict]): If the decryption key or iv is
changing in the middle of the request, you can set it here
"""
if (
"Signature" in data
and data["Signature"].get("onglet") not in self.authorized_onglets
):
raise PronoteAPIError(
"Action not permitted. (onglet is not normally accessible)"
)
post_data: Union[dict, str] = data
if self.compress_requests:
# takes care of compression. it is done with zlib, with compression level set to 6. the headers
# are stripped, and the output is converted to hex
log.debug("[_Communication.post] compressing data")
post_data = jsn.dumps(post_data).encode().hex()
log.debug(post_data)
post_data = zlib.compress(post_data.encode(), level=6)[2:-4].hex().upper()
if self.encrypt_requests:
# encryption is done with the communicated key, the output is converted to hex (the client makes the output
# all CAPS, so we're doing the same)
log.debug("[_Communication.post] encrypt data")
if type(post_data) == dict:
# get the data in json form, then proceed to encrypt
post_data = (
self.encryption.aes_encrypt(jsn.dumps(post_data).encode())
.hex()
.upper()
)
elif type(post_data) == str:
# we can assume the post_data is from compression, we need to get back the raw bytes
post_data = (
self.encryption.aes_encrypt(bytes.fromhex(post_data)).hex().upper()
)
# creating the full json dict
r_number = self.encryption.aes_encrypt(str(self.request_number).encode()).hex()
json = {
"session": int(self.attributes["h"]),
"no": r_number,
"id": function_name,
"dataSec": post_data,
}
log.debug("[_Communication.post] sending post request: %s", json)
p_site = f'{self.root_site}/appelfonction/{self.attributes["a"]}/{self.attributes["h"]}/{r_number}'
response: Response = self.session.request(
"POST", p_site, json=json, cookies=self.cookies
)
self.request_number += 2
self.last_ping = int(time())
self.last_response = response
# error protection
if not response.ok:
raise PronoteAPIError(f"Bad request (http status: {response.status_code})")
if "Erreur" in response.json():
r_json = response.json()
if r_json["Erreur"]["G"] == 22:
raise ExpiredObject(error_messages.get(22))
raise PronoteAPIError(
error_messages.get(
r_json["Erreur"]["G"],
f'Unknown error from pronote: {r_json["Erreur"]["G"]} '
f'| {r_json["Erreur"]["Titre"]}',
),
pronote_error_code=r_json["Erreur"]["G"],
pronote_error_msg=r_json["Erreur"]["Titre"],
)
# TODO: check returned request_number
# checking for decryption change
if decryption_change is not None:
log.debug("[_Communication.post] decryption change")
if "iv" in decryption_change:
self.encryption.aes_iv = decryption_change["iv"]
if "key" in decryption_change:
self.encryption.aes_key = decryption_change["key"]
response_data = response.json()
if self.encrypt_requests:
# decrypt the received message, the output will either be a hex string, or the json dictionary
log.debug("[_Communication.post] decrypting")
decrypted: bytes = self.encryption.aes_decrypt(
bytes.fromhex(response_data["dataSec"])
)
if not self.compress_requests:
response_data["dataSec"] = jsn.loads(decrypted.decode())
else:
response_data["dataSec"] = decrypted
if self.compress_requests:
log.debug("[_Communication.post] decompressing")
d: Union[bytes, str] = response_data["dataSec"]
try:
response_data["dataSec"] = jsn.loads(
zlib.decompress(
bytes.fromhex(d) if type(d) == str else d, wbits=-15 # type: ignore
).decode()
)
except jsn.JSONDecodeError:
raise PronoteAPIError("JSONDecodeError while requesting from pronote.")
return response_data
def after_auth(self, data: dict, auth_key: bytes) -> None:
"""
Key change after the authentification was successful.
Args:
auth_key (bytes): AES authentification key used to calculate the challenge (From password of the user)
data (dict): Data from the request
"""
self.encryption.aes_key = auth_key
if not self.cookies:
self.cookies = self.last_response.cookies
work = self.encryption.aes_decrypt(
bytes.fromhex(data["dataSec"]["data"]["cle"])
)
key = MD5.new(_enBytes(work.decode()))
self.encryption.aes_key = key.digest()
def _parse_html(self, html: bytes) -> dict:
"""Parses the html for the RSA keys
Returns:
dict: HTML attributes
"""
parsed = BeautifulSoup(html, "html.parser")
onload = parsed.find(id="id_body")
if onload:
match = re.search(r"Start ?\({(?P<param>[^}]*)}\)", onload["onload"]) # type: ignore
if not match:
raise PronoteAPIError(
"Page html is different than expected. Be sure that pronote_url is the direct url to your pronote page."
)
onload_c = match.group("param")
elif b"IP" in html:
raise PronoteAPIError("Your IP address is suspended.")
else:
raise PronoteAPIError(
"Page html is different than expected. Be sure that pronote_url is the direct url to your pronote page."
)
attributes = {}
for attr in onload_c.split(","): # type: ignore
key, value = attr.split(":")
attributes[key] = value.replace("'", "")
if "h" not in attributes:
raise ValueError("internal exception to retry -> cannot prase html")
return attributes
@staticmethod
def get_root_address(addr: str) -> tuple[str, str]:
return "/".join(addr.split("/")[:-1]), "/".join(addr.split("/")[-1:])
def _enleverAlea(text: str) -> str:
"""Gets rid of the stupid thing that they did, idk what it really is for, but i guess it adds security"""
sansalea = [b for i, b in enumerate(text) if i % 2 == 0]
return "".join(sansalea)
def _enBytes(string: str) -> bytes:
list_string = string.split(",")
return bytes(int(i) for i in list_string)
def _prepare_onglets(list_of_onglets): # type: ignore
output = []
if type(list_of_onglets) != list:
return [list_of_onglets]
for item in list_of_onglets:
if type(item) == dict:
item = list(item.values())
output.extend(_prepare_onglets(item))
return output
class _Encryption(object):
# taken from eleve.js
RSA_1024_MODULO = 130337874517286041778445012253514395801341480334668979416920989365464528904618150245388048105865059387076357492684573172203245221386376405947824377827224846860699130638566643129067735803555082190977267155957271492183684665050351182476506458843580431717209261903043895605014125081521285387341454154194253026277
RSA_1024_EXPONENT = 65537
def __init__(self) -> None:
"""The encryption part of the API. You shouldn't have to use this normally."""
# aes
self.aes_iv = bytes(16)
self.aes_iv_temp = secrets.token_bytes(16)
self.aes_key = MD5.new().digest()
# rsa
self.rsa_keys: dict[str, str] = {}
def aes_encrypt(self, data: bytes) -> bytes:
cipher = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv)
padded = Padding.pad(data, 16)
return cipher.encrypt(padded)
def aes_decrypt(self, data: bytes) -> bytes:
cipher = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv)
try:
return Padding.unpad(cipher.decrypt(data), 16)
except ValueError:
raise CryptoError(
"Decryption failed while trying to un pad. (probably bad decryption key/iv)"
)
def aes_set_iv(self, iv: Optional[bytes] = None) -> None:
self.aes_iv = iv or MD5.new(self.aes_iv_temp).digest()
def aes_set_key(self, key: Optional[bytes] = None) -> None:
if key:
self.aes_key = MD5.new(key).digest()
def rsa_encrypt(self, data: bytes) -> bytes:
key = RSA.construct((self.RSA_1024_MODULO, self.RSA_1024_EXPONENT))
# noinspection PyTypeChecker
pkcs = PKCS1_v1_5.new(key)
return pkcs.encrypt(data)
class _KeepAlive(threading.Thread):
def __init__(self, client: ClientBase) -> None:
super().__init__(target=self.alive)
self._client = client
self.keep_alive = True
def alive(self) -> None:
while self.keep_alive:
# The delay set in eleve.js is 2 * 60 * 1000 ms (2 minutes)
if time() - self._client.communication.last_ping >= 110:
self._client.post("Navigation", 7, {"onglet": 7, "ongletPrec": 7})
sleep(1)
def __enter__(self) -> None:
self.start()
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
self.keep_alive = False
self.join()