diff --git a/.gitignore b/.gitignore index a530e14..89b3630 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Akademiya +/.vscode books/ +/bin/*.exe # Python-generated files __pycache__/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b881eff..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "python.analysis.autoImportCompletions": true -} \ No newline at end of file diff --git a/README.md b/README.md index e3c379a..a6c8bc4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,32 @@ pip install git+https://git.tretrauit.me/tretrauit/akademiya + [x] Authentication + [x] Look up books + [x] Borrow books -+ [x] Download (encrypted) books ++ [x] Download & decrypt books + +## Building + +1. (Optional) You'll need to build the C++ part first, which can be built using CMake: +> [!WARNING] +> If you don't build the decryptor then the download function will not work. +```bash +cd decypherer +mkdir build +cd build +# Linux & other platforms (Cross-compile) +cmake -D CMAKE_BUILD_TYPE=Release .. +ninja +# Windows (You need to have MSVC installed) +cmake -G "Visual Studio 17" -DCMAKE_GENERATOR_PLATFORM=WIN32 .. +cmake --build . --config Release +``` + +2. Copy `decypherer.exe` to `bin/` directory. +3. Run `python -m akademiya` to use as normal. + + +## Acknowledgements + ++ [gawgua](https://github.com/gawgua): Reverse engineered the encryption system and wrote the decryption code. ## License diff --git a/akademiya/__main__.py b/akademiya/__main__.py index 5195df7..dd13fcd 100644 --- a/akademiya/__main__.py +++ b/akademiya/__main__.py @@ -1,6 +1,6 @@ from getpass import getpass from pathlib import Path -from akademiya import __version__, Client +from akademiya import __version__, Client, constants, utils from traceback import print_exc # Import readline if available (Unix-like systems) @@ -29,9 +29,13 @@ def download_book(client: Client, book_id: int): if pdf_bytes is None: print("The server didn't return any PDF data.") return + constants.PLATFORM_DIRS.site_cache_path.mkdir(parents=True, exist_ok=True) + temp_path = constants.PLATFORM_DIRS.site_cache_path / f"{book.ID}.pdf" pdf_path = base_path / f"{book.ID}.pdf" - with pdf_path.open("wb") as f: + with temp_path.open("wb") as f: f.write(pdf_bytes) + print("Decrypting book...") + utils.decrypt_book_file(temp_path, pdf_path) print(f"Book downloaded to '{pdf_path.resolve()}'") diff --git a/akademiya/client.py b/akademiya/client.py index 173ef87..8696733 100644 --- a/akademiya/client.py +++ b/akademiya/client.py @@ -4,6 +4,8 @@ from akademiya import constants from dataclasses import dataclass from urllib.parse import quote +from akademiya.internal import utils + @dataclass class BookPdf: @@ -86,11 +88,12 @@ class Client: self._session.headers.update({"User-Agent": constants.USER_AGENT}) self._app_version: float = None self.user: User = None + self.unique_key = utils.get_unique_key() self.set_app_version() def borrow_book(self, book_id: int): rsp = self._session.get( - f"{constants.ENDPOINT}/API/cbs/User/Payment/{self.user.token}/{book_id}/{constants.MAGIC_KEY}/1/1/" + f"{constants.ENDPOINT}/API/cbs/User/Payment/{self.user.token}/{book_id}/{self.unique_key}/1/1/" ) rsp.raise_for_status() result = rsp.json()["BookBorrowsResult"] @@ -125,7 +128,7 @@ class Client: raise Exception("You must borrow the book before downloading the PDF.") pdf = book.product_pdf[0] rsp = self._session.get( - f"{pdf.Url}{book.product_code}/{constants.MAGIC_KEY}/APPWINDOW.1.0/{self.user.token}/{self.user.username}/1/1/" + f"{pdf.Url}{book.product_code}/{self.unique_key}/APPWINDOW.1.0/{self.user.token}/{self.user.username}/1/1/" ) rsp.raise_for_status() # Comment so Pylance can stfu @@ -141,10 +144,10 @@ class Client: def is_borrowing(self, book_id: int) -> bool: """ Checks if the user is borrowing a book. - + Args: book_id (int): The ID of the book to check. - + Returns: bool: True if the user is borrowing the book, False otherwise.""" rsp = self._session.get( diff --git a/akademiya/constants.py b/akademiya/constants.py index f94022e..f627fd8 100644 --- a/akademiya/constants.py +++ b/akademiya/constants.py @@ -1,3 +1,6 @@ +from platformdirs import PlatformDirs + + USER_AGENT = "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.2; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; Tablet PC 2.0)" ENDPOINT = "http://bookworm.vnu.edu.vn" -MAGIC_KEY = "CA72dacfce21b55c" +PLATFORM_DIRS = PlatformDirs("akademiya", "tretrauit") diff --git a/akademiya/internal/__init__.py b/akademiya/internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/akademiya/internal/utils.py b/akademiya/internal/utils.py new file mode 100644 index 0000000..09d72a3 --- /dev/null +++ b/akademiya/internal/utils.py @@ -0,0 +1,74 @@ +# Thanks gawgua for all the reverse engineering work he has done. +# Almost all codes here are from him, I just made some changes to make it work with my project. +import hashlib +import platform +import random +from akademiya.constants import PLATFORM_DIRS + + +def _nt_get_window_id(): + import clr # type: ignore + import hashlib + + clr.AddReference("System.Management") + from System.Management import ManagementScope, ManagementObjectSearcher, ObjectQuery # type: ignore + + def getCSProductUUID(): + managementScope = ManagementScope("\\\\.\\ROOT\\cimv2") + objectQuery = ObjectQuery("SELECT * FROM Win32_ComputerSystemProduct") + managementObjectCollection = ManagementObjectSearcher( + managementScope, objectQuery + ).Get() + text = "" + for managemenBaseObject in managementObjectCollection: + text += managemenBaseObject["UUID"] + if text != "" and text.strip() != "": + break + return text + + def getDiskDriveSerial(): + managementScope = ManagementScope("\\\\.\\ROOT\\cimv2") + objectQuery = ObjectQuery("SELECT * FROM Win32_DiskDrive") + managementObjectCollection = ManagementObjectSearcher( + managementScope, objectQuery + ).Get() + text = "" + for managemenBaseObject in managementObjectCollection: + text += managemenBaseObject["UUID"] + if text != "" and text.strip() != "": + break + return text + + text = getCSProductUUID() + if text == "": + text = getDiskDriveSerial() + text = text.replace("-", "") + "WINDOWID" + return hashlib.md5(text.encode("utf-8")).hexdigest()[0:16] + + +def calculate_window_id(): + PLATFORM_DIRS.site_data_path.mkdir(parents=True, exist_ok=True) + if PLATFORM_DIRS.site_data_path.joinpath("window_id").is_file(): + return PLATFORM_DIRS.site_data_path.joinpath("window_id").read_text().strip() + else: + result: str = "" + if platform.system() == "Windows": + result = _nt_get_window_id() + else: + result = random.randbytes(16).hex()[0:16] + PLATFORM_DIRS.site_data_path.joinpath("window_id").write_text(result) + return result + + +def calculate_serial(): + text = calculate_window_id() + "SERIAL" + return hashlib.md5(text.encode("utf-8")).hexdigest()[0:16] + + +def get_unique_key(): + t = calculate_window_id() + t2 = calculate_serial() + t4 = f"{t}#{t2}#{t2}" + t6 = "CA" + hashlib.md5(t4.encode("utf-8")).hexdigest()[-10:] + t8 = hashlib.md5(t6.encode("utf-8")).hexdigest()[-4:] + return t6 + t8 diff --git a/akademiya/utils.py b/akademiya/utils.py new file mode 100644 index 0000000..5c47a62 --- /dev/null +++ b/akademiya/utils.py @@ -0,0 +1,32 @@ +from pathlib import Path +from subprocess import call +from shutil import move, copy +from os import PathLike +from random import randint +from akademiya.constants import PLATFORM_DIRS +from akademiya.internal import utils as _utils + + +def decrypt_book_file(book_file: PathLike, out_path: PathLike): + """ + Decrypts the book file, only works if the book is downloaded by this client. + + Args: + book_file (PathLike): The path to the encrypted book file. + + Returns: + bytes: The decrypted book data. + """ + cache_path = PLATFORM_DIRS.site_cache_path + cache_path.mkdir(exist_ok=True) + random_name_input = str(randint(100000, 999999)) + input_file = cache_path.joinpath(random_name_input) + random_name_output = str(randint(100000, 999999)) + output_file = cache_path.joinpath(random_name_output) + copy(book_file, input_file) + window_id = _utils.calculate_window_id() + retcode = call(["./bin/decypherer.exe", window_id, str(input_file), str(output_file)]) + Path(input_file).unlink() + if retcode != 0: + raise RuntimeError("Failed to decrypt the book file.") + move(output_file, out_path) diff --git a/bin/mupdf-exp-dll-x86.dll b/bin/mupdf-exp-dll-x86.dll new file mode 100644 index 0000000..860437b Binary files /dev/null and b/bin/mupdf-exp-dll-x86.dll differ diff --git a/decypherer/.gitignore b/decypherer/.gitignore new file mode 100644 index 0000000..e59280d --- /dev/null +++ b/decypherer/.gitignore @@ -0,0 +1,34 @@ +### C++ template +cmake-*/ +.idea/ + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + + diff --git a/decypherer/CMakeLists.txt b/decypherer/CMakeLists.txt new file mode 100644 index 0000000..b5f979a --- /dev/null +++ b/decypherer/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.30) +project(decypherer) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_FLAGS -m32) + +add_executable(decypherer main.cpp) diff --git a/decypherer/main.cpp b/decypherer/main.cpp new file mode 100644 index 0000000..4b22fce --- /dev/null +++ b/decypherer/main.cpp @@ -0,0 +1,79 @@ +// Again, this is ported C++ code from gawgua's original Python code. +// All credits go to him, I just used DeepSeek to convert it to C++. + +#include +#include +#include +#include + +// MuPDF structure definition +struct pdf_write_options { + int do_incremental; + int do_pretty; + int do_ascii; + int do_compress; + int do_compress_images; + int do_compress_fonts; + int do_decompress; + int do_garbage; + int do_linear; + int do_clean; + int do_sanitize; + int do_appearance; + int do_encrypt; + int dont_regenerate_id; + int permissions; + char opwd_utf8[128]; + char upwd_utf8[128]; + int do_snapshot; + int do_preserve_metadata; + int do_use_objstms; + int compression_effort; +}; + +int main(const int argc, char* argv[]) +{ + if (argc < 3) { + std::cout << "usage: decypherer.exe " << std::endl; + return 0; + } + // Arg 1 + auto window_id = argv[1]; + // Arg 2 + auto path = argv[2]; + // Arg 3 + const auto output = argv[3]; + + const HMODULE mupdf = LoadLibraryA("mupdf-exp-dll-x86.dll"); + if (mupdf == nullptr) { + std::cout << "error: failed to load MuPDF library" << std::endl; + return 1; + } + + // Define function pointers + const auto fz_new_context_imp = reinterpret_cast(GetProcAddress(mupdf, "fz_new_context_imp")); + const auto fz_register_document_handlers = reinterpret_cast(GetProcAddress(mupdf, "fz_register_document_handlers")); + const auto fz_open_document_w = reinterpret_cast(GetProcAddress(mupdf, "fz_open_document_w")); + const auto pdf_save_document = reinterpret_cast(GetProcAddress(mupdf, "pdf_save_document")); + + // Build argument path + auto arg_path_str = std::format("#OCB#{}#{}", window_id, path); + const std::wstring arg_path(arg_path_str.begin(), arg_path_str.end()); + + // Create context + void* ctx = fz_new_context_imp(nullptr, nullptr, 268435456, "1.16.1"); + fz_register_document_handlers(ctx); + + // Open document + void* doc = fz_open_document_w(ctx, arg_path.c_str()); + + // Set save options + pdf_write_options opts = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, ~0, + {}, {}, 0, 0, 0, 0 + }; + + // Save document + pdf_save_document(ctx, doc, output, &opts); + FreeLibraryAndExitThread(mupdf, 0); +} diff --git a/pyproject.toml b/pyproject.toml index bbdbe5a..4817f22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,15 @@ [project] name = "akademiya" version = "0.1.0" -description = "Add your description here" +description = "Unofficial API client written in Python for a certain book library." readme = "README.md" requires-python = ">=3.13" dependencies = [ + "platformdirs>=4.3.6", "requests>=2.32.3", ] + +[project.optional-dependencies] +win = [ + "pythonnet>=3.0.5", +] diff --git a/uv.lock b/uv.lock index 3cfe7ca..0fc76d6 100644 --- a/uv.lock +++ b/uv.lock @@ -6,11 +6,21 @@ name = "akademiya" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "platformdirs" }, { name = "requests" }, ] +[package.optional-dependencies] +win = [ + { name = "pythonnet" }, +] + [package.metadata] -requires-dist = [{ name = "requests", specifier = ">=2.32.3" }] +requires-dist = [ + { name = "platformdirs", specifier = ">=4.3.6" }, + { name = "pythonnet", marker = "extra == 'win'", specifier = ">=3.0.5" }, + { name = "requests", specifier = ">=2.32.3" }, +] [[package]] name = "certifi" @@ -21,6 +31,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + [[package]] name = "charset-normalizer" version = "3.4.0" @@ -45,6 +77,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, ] +[[package]] +name = "clr-loader" +version = "0.2.7.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b3/8ae917e458394e2cebdbf17bed0a8204f8d4ffc79a093a7b1141c7731d3c/clr_loader-0.2.7.post0.tar.gz", hash = "sha256:b7a8b3f8fbb1bcbbb6382d887e21d1742d4f10b5ea209e4ad95568fe97e1c7c6", size = 56701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/c0/06e64a54bced4e8b885c1e7ec03ee1869e52acf69e87da40f92391a214ad/clr_loader-0.2.7.post0-py3-none-any.whl", hash = "sha256:e0b9fcc107d48347a4311a28ffe3ae78c4968edb216ffb6564cb03f7ace0bb47", size = 50649 }, +] + [[package]] name = "idna" version = "3.10" @@ -54,6 +98,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pythonnet" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "clr-loader" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506 }, +] + [[package]] name = "requests" version = "2.32.3"