diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3a65d..a800725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,3 +54,4 @@ List of changes since the fork of Apprentice Harper's repository: - ineptpdf: Support for V=5, R=5 and R=6 PDF files, and for AES256-encrypted PDFs. - ineptpdf: Disable cross-reference streams in the output file. This may make PDFs slightly larger, but the current code for cross-reference streams seems to be buggy and sometimes creates corrupted PDFs. - Drop support for importing key data from the ancient, pre "DeDRM" Calibre plugins ("Ignoble Epub DeDRM", "eReader PDB 2 PML" and "K4MobiDeDRM"). These are from 2011, I doubt anyone still has these installed, I can't even find a working link for these to test them. If you still have encryption keys in one of these plugins, you will need to update to DeDRM v10.0.2 or older (to convert the keys) before updating to DeDRM v10.0.3 or newer. +- Some Python3 bugfixes for Amazon books (merged #10 by ableeker). \ No newline at end of file diff --git a/DeDRM_plugin/DeDRM_Help.htm b/DeDRM_plugin/DeDRM_Help.htm index c5a92ac..c94d074 100644 --- a/DeDRM_plugin/DeDRM_Help.htm +++ b/DeDRM_plugin/DeDRM_Help.htm @@ -17,7 +17,7 @@ p {margin-top: 0} -

DeDRM Plugin (v10.0.0)

+

DeDRM Plugin (v10.0.2)

This plugin removes DRM from ebooks when they are imported into calibre. If you already have DRMed ebooks in your calibre library, you will need to remove them and import them again.

diff --git a/DeDRM_plugin/__calibre_compat_code.py b/DeDRM_plugin/__calibre_compat_code.py index 4896dd6..a535a42 100644 --- a/DeDRM_plugin/__calibre_compat_code.py +++ b/DeDRM_plugin/__calibre_compat_code.py @@ -2,13 +2,19 @@ #@@CALIBRE_COMPAT_CODE_START@@ import sys, os -# Explicitly allow importing the parent folder +# Explicitly allow importing everything ... if os.path.dirname(os.path.dirname(os.path.abspath(__file__))) not in sys.path: sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if os.path.dirname(os.path.abspath(__file__)) not in sys.path: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Bugfix for Calibre < 5: if "calibre" in sys.modules and sys.version_info[0] == 2: from calibre.utils.config import config_dir if os.path.join(config_dir, "plugins", "DeDRM.zip") not in sys.path: sys.path.insert(0, os.path.join(config_dir, "plugins", "DeDRM.zip")) + +# Explicitly set the package identifier so we are allowed to import stuff ... +#__package__ = "DeDRM_plugin" + #@@CALIBRE_COMPAT_CODE_END@@ diff --git a/DeDRM_plugin/__init__.py b/DeDRM_plugin/__init__.py index ac11d0f..651647e 100644 --- a/DeDRM_plugin/__init__.py +++ b/DeDRM_plugin/__init__.py @@ -8,7 +8,6 @@ from __future__ import print_function # Copyright © 2021 NoDRM __license__ = 'GPL v3' -__version__ = '10.0.2' __docformat__ = 'restructuredtext en' @@ -88,12 +87,6 @@ __docformat__ = 'restructuredtext en' Decrypt DRMed ebooks. """ -PLUGIN_NAME = "DeDRM" -PLUGIN_VERSION_TUPLE = tuple([int(x) for x in __version__.split(".")]) -PLUGIN_VERSION = ".".join([str(x)for x in PLUGIN_VERSION_TUPLE]) -# Include an html helpfile in the plugin's zipfile with the following name. -RESOURCE_NAME = PLUGIN_NAME + '_Help.htm' - import codecs import sys, os import time @@ -101,6 +94,8 @@ import traceback #@@CALIBRE_COMPAT_CODE@@ +import __version + class DeDRMError(Exception): pass @@ -147,6 +142,10 @@ class SafeUnbuffered: def __getattr__(self, attr): return getattr(self.stream, attr) +PLUGIN_NAME = __version.PLUGIN_NAME +PLUGIN_VERSION = __version.PLUGIN_VERSION +PLUGIN_VERSION_TUPLE = __version.PLUGIN_VERSION_TUPLE + class DeDRM(FileTypePlugin): name = PLUGIN_NAME description = "Removes DRM from Amazon Kindle, Adobe Adept (including Kobo), Readium LCP, Barnes & Noble, Mobipocket and eReader ebooks. Credit given to i♥cabbages and The Dark Reverser for the original stand-alone scripts." diff --git a/DeDRM_plugin/__version.py b/DeDRM_plugin/__version.py new file mode 100644 index 0000000..db5eaa1 --- /dev/null +++ b/DeDRM_plugin/__version.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +#@@CALIBRE_COMPAT_CODE@@ + +PLUGIN_NAME = "DeDRM" +__version__ = '10.0.2' + +PLUGIN_VERSION_TUPLE = tuple([int(x) for x in __version__.split(".")]) +PLUGIN_VERSION = ".".join([str(x)for x in PLUGIN_VERSION_TUPLE]) +# Include an html helpfile in the plugin's zipfile with the following name. +RESOURCE_NAME = PLUGIN_NAME + '_Help.htm' \ No newline at end of file diff --git a/DeDRM_plugin/config.py b/DeDRM_plugin/config.py index 79e7c4d..fa92fa2 100755 --- a/DeDRM_plugin/config.py +++ b/DeDRM_plugin/config.py @@ -28,7 +28,7 @@ from calibre.constants import iswindows, isosx from __init__ import PLUGIN_NAME, PLUGIN_VERSION -from __init__ import RESOURCE_NAME as help_file_name +from __version import RESOURCE_NAME as help_file_name from utilities import uStrCmp import prefs diff --git a/DeDRM_plugin/prefs.py b/DeDRM_plugin/prefs.py index 4db9618..0ae3943 100755 --- a/DeDRM_plugin/prefs.py +++ b/DeDRM_plugin/prefs.py @@ -12,12 +12,20 @@ import traceback #@@CALIBRE_COMPAT_CODE@@ -from calibre.utils.config import JSONConfig +try: + from calibre.utils.config import JSONConfig +except: + from standalone.jsonconfig import JSONConfig + from __init__ import PLUGIN_NAME class DeDRM_Prefs(): - def __init__(self): - JSON_PATH = os.path.join("plugins", PLUGIN_NAME.strip().lower().replace(' ', '_') + '.json') + def __init__(self, json_path=None): + if json_path is None: + JSON_PATH = os.path.join("plugins", PLUGIN_NAME.strip().lower().replace(' ', '_') + '.json') + else: + JSON_PATH = json_path + self.dedrmprefs = JSONConfig(JSON_PATH) self.dedrmprefs.defaults['configured'] = False diff --git a/DeDRM_plugin/standalone/__init__.py b/DeDRM_plugin/standalone/__init__.py index fe74bb3..e5149bc 100644 --- a/DeDRM_plugin/standalone/__init__.py +++ b/DeDRM_plugin/standalone/__init__.py @@ -9,10 +9,11 @@ from __future__ import absolute_import, print_function OPT_SHORT_TO_LONG = [ ["c", "config"], - ["d", "dest"], ["e", "extract"], ["f", "force"], ["h", "help"], + ["i", "import"], + ["o", "output"], ["p", "password"], ["q", "quiet"], ["t", "test"], @@ -22,8 +23,6 @@ OPT_SHORT_TO_LONG = [ #@@CALIBRE_COMPAT_CODE@@ -# Explicitly set the package identifier so we are allowed to import stuff ... -__package__ = "DeDRM_plugin" import os, sys @@ -34,6 +33,9 @@ _additional_data = [] _additional_params = [] _function = None +global config_file_path +config_file_path = "dedrm.json" + def print_fname(f, info): print(" " + f.ljust(15) + " " + info) @@ -64,7 +66,7 @@ def print_err_header(): print() def print_help(): - from __init__ import PLUGIN_NAME, PLUGIN_VERSION + from __version import PLUGIN_NAME, PLUGIN_VERSION print(PLUGIN_NAME + " v" + PLUGIN_VERSION + " - DRM removal plugin by noDRM") print("Based on DeDRM Calibre plugin by Apprentice Harper, Apprentice Alf and others.") print("See https://github.com/noDRM/DeDRM_tools for more information.") @@ -78,12 +80,13 @@ def print_help(): print() print("Available functions:") print_fname("passhash", "Manage Adobe PassHashes") + print_fname("remove_drm", "Remove DRM from one or multiple books") print() # TODO: All parameters that are global should be listed here. def print_credits(): - from __init__ import PLUGIN_NAME, PLUGIN_VERSION + from __version import PLUGIN_NAME, PLUGIN_VERSION print(PLUGIN_NAME + " v" + PLUGIN_VERSION + " - Calibre DRM removal plugin by noDRM") print("Based on DeDRM Calibre plugin by Apprentice Harper, Apprentice Alf and others.") print("See https://github.com/noDRM/DeDRM_tools for more information.") @@ -105,18 +108,28 @@ def print_credits(): def handle_single_argument(arg, next): used_up = 0 global _additional_params + global config_file_path - if arg in ["--username", "--password"]: + if arg in ["--username", "--password", "--output", "--outputdir"]: used_up = 1 _additional_params.append(arg) - if next is None: + if next is None or len(next) == 0: print_err_header() print("Missing parameter for argument " + arg, file=sys.stderr) sys.exit(1) else: _additional_params.append(next[0]) + + elif arg == "--config": + if next is None or len(next) == 0: + print_err_header() + print("Missing parameter for argument " + arg, file=sys.stderr) + sys.exit(1) - elif arg in ["--help", "--credits", "--verbose", "--quiet", "--extract"]: + config_file_path = next[0] + used_up = 1 + + elif arg in ["--help", "--credits", "--verbose", "--quiet", "--extract", "--import", "--overwrite", "--force"]: _additional_params.append(arg) @@ -143,12 +156,28 @@ def handle_data(data): def execute_action(action, filenames, params): print("Executing '{0}' on file(s) {1} with parameters {2}".format(action, str(filenames), str(params)), file=sys.stderr) - if action == "passhash": + if action == "help": + print_help() + sys.exit(0) + + elif action == "passhash": from standalone.passhash import perform_action perform_action(params, filenames) + + elif action == "remove_drm": + if not os.path.isfile(os.path.abspath(config_file_path)): + print("Config file missing ...") + + from standalone.remove_drm import perform_action + perform_action(params, filenames) + + elif action == "config": + import prefs + config = prefs.DeDRM_Prefs(os.path.abspath(config_file_path)) + print(config["adeptkeys"]) else: - print("ERROR: This feature is still in development. Right now it can't be used yet.", file=sys.stderr) + print("Command '"+action+"' is unknown.", file=sys.stderr) def main(argv): @@ -236,7 +265,7 @@ def main(argv): # This function gets told what to do and gets additional data (filenames). # It also receives additional parameters. # The rest of the code will be in different Python files. - execute_action(_function, _additional_data, _additional_params) + execute_action(_function.lower(), _additional_data, _additional_params) diff --git a/DeDRM_plugin/standalone/jsonconfig.py b/DeDRM_plugin/standalone/jsonconfig.py new file mode 100644 index 0000000..a4149bf --- /dev/null +++ b/DeDRM_plugin/standalone/jsonconfig.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# CLI interface for the DeDRM plugin (useable without Calibre, too) +# Config implementation + +from __future__ import absolute_import, print_function + +# Taken from Calibre code - Copyright © 2008, Kovid Goyal kovid@kovidgoyal.net, GPLv3 + +#@@CALIBRE_COMPAT_CODE@@ + +import sys, os, codecs, json + +config_dir = "/" +CONFIG_DIR_MODE = 0o700 +iswindows = sys.platform.startswith('win') + + +filesystem_encoding = sys.getfilesystemencoding() +if filesystem_encoding is None: + filesystem_encoding = 'utf-8' +else: + try: + if codecs.lookup(filesystem_encoding).name == 'ascii': + filesystem_encoding = 'utf-8' + # On linux, unicode arguments to os file functions are coerced to an ascii + # bytestring if sys.getfilesystemencoding() == 'ascii', which is + # just plain dumb. This is fixed by the icu.py module which, when + # imported changes ascii to utf-8 + except Exception: + filesystem_encoding = 'utf-8' + + +class JSONConfig(dict): + + EXTENSION = '.json' + + + def __init__(self, rel_path_to_cf_file, base_path=config_dir): + dict.__init__(self) + self.no_commit = False + self.defaults = {} + self.file_path = os.path.join(base_path, + *(rel_path_to_cf_file.split('/'))) + self.file_path = os.path.abspath(self.file_path) + if not self.file_path.endswith(self.EXTENSION): + self.file_path += self.EXTENSION + + self.refresh() + + def mtime(self): + try: + return os.path.getmtime(self.file_path) + except OSError: + return 0 + + def touch(self): + try: + os.utime(self.file_path, None) + except OSError: + pass + + + def decouple(self, prefix): + self.file_path = os.path.join(os.path.dirname(self.file_path), prefix + os.path.basename(self.file_path)) + self.refresh() + + def refresh(self, clear_current=True): + d = {} + if os.path.exists(self.file_path): + with open(self.file_path, "rb") as f: + raw = f.read() + try: + d = self.raw_to_object(raw) if raw.strip() else {} + except SystemError: + pass + except: + import traceback + traceback.print_exc() + d = {} + if clear_current: + self.clear() + self.update(d) + + def has_key(self, key): + return dict.__contains__(self, key) + + def set(self, key, val): + self.__setitem__(key, val) + + def __delitem__(self, key): + try: + dict.__delitem__(self, key) + except KeyError: + pass # ignore missing keys + else: + self.commit() + + def commit(self): + if self.no_commit: + return + if hasattr(self, 'file_path') and self.file_path: + dpath = os.path.dirname(self.file_path) + if not os.path.exists(dpath): + os.makedirs(dpath, mode=CONFIG_DIR_MODE) + with open(self.file_path, "w") as f: + raw = self.to_raw() + f.seek(0) + f.truncate() + f.write(raw) + + def __enter__(self): + self.no_commit = True + + def __exit__(self, *args): + self.no_commit = False + self.commit() + + def raw_to_object(self, raw): + return json.loads(raw) + + def to_raw(self): + return json.dumps(self, ensure_ascii=False) + + def __getitem__(self, key): + try: + return dict.__getitem__(self, key) + except KeyError: + return self.defaults[key] + + def get(self, key, default=None): + try: + return dict.__getitem__(self, key) + except KeyError: + return self.defaults.get(key, default) + + def __setitem__(self, key, val): + dict.__setitem__(self, key, val) + self.commit() \ No newline at end of file diff --git a/DeDRM_plugin/standalone/passhash.py b/DeDRM_plugin/standalone/passhash.py index 215f283..f7bf565 100644 --- a/DeDRM_plugin/standalone/passhash.py +++ b/DeDRM_plugin/standalone/passhash.py @@ -18,18 +18,19 @@ iswindows = sys.platform.startswith('win') isosx = sys.platform.startswith('darwin') def print_passhash_help(): - from __init__ import PLUGIN_NAME, PLUGIN_VERSION + from __version import PLUGIN_NAME, PLUGIN_VERSION print(PLUGIN_NAME + " v" + PLUGIN_VERSION + " - Calibre DRM removal plugin by noDRM") print() print("passhash: Manage Adobe PassHashes") print() - print_std_usage("passhash", "[ -u username -p password | -e ]") + print_std_usage("passhash", "[ -u username -p password | -b base64str ] [ -i ] ") print() print("Options: ") print_opt("u", "username", "Generate a PassHash with the given username") - print_opt("p", "password", "Generate a PassHash with the given username") - print_opt("e", "extract", "Extract PassHashes found on this machine") + print_opt("p", "password", "Generate a PassHash with the given password") + print_opt("e", "extract", "Display PassHashes found on this machine") + print_opt("i", "import", "Import hashes into the JSON config file") def perform_action(params, files): user = None @@ -40,6 +41,7 @@ def perform_action(params, files): return 0 extract = False + import_to_json = True while len(params) > 0: p = params.pop(0) @@ -52,21 +54,34 @@ def perform_action(params, files): elif p == "--help": print_passhash_help() return 0 + elif p == "--import": + import_to_json = True - if not extract: + if not extract and not import_to_json: if user is None: print("Missing parameter: --username", file=sys.stderr) if pwd is None: print("Missing parameter: --password", file=sys.stderr) if user is None or pwd is None: return 1 + + if user is None and pwd is not None: + print("Parameter --password also requires --username", file=sys.stderr) + return 1 + if user is not None and pwd is None: + print("Parameter --username also requires --password", file=sys.stderr) + return 1 if user is not None and pwd is not None: from ignoblekeyGenPassHash import generate_key key = generate_key(user, pwd) + if import_to_json: + # TODO: Import the key to the JSON + pass + print(key.decode("utf-8")) - if extract: + if extract or import_to_json: if not iswindows and not isosx: print("Extracting PassHash keys not supported on Linux.", file=sys.stderr) return 1 @@ -92,11 +107,16 @@ def perform_action(params, files): # Print all found keys for k in newkeys: - print(k) + if import_to_json: + # TODO: Add keys to json + pass + + if extract: + print(k) return 0 if __name__ == "__main__": - print("This code is not intended to be executed directly!") \ No newline at end of file + print("This code is not intended to be executed directly!", file=sys.stderr) \ No newline at end of file diff --git a/DeDRM_plugin/standalone/remove_drm.py b/DeDRM_plugin/standalone/remove_drm.py new file mode 100644 index 0000000..5ab5f33 --- /dev/null +++ b/DeDRM_plugin/standalone/remove_drm.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# CLI interface for the DeDRM plugin (useable without Calibre, too) +# DRM removal + +from __future__ import absolute_import, print_function + +# Copyright © 2021 NoDRM + +#@@CALIBRE_COMPAT_CODE@@ + +import os, sys + +from zipfile import ZipInfo, ZipFile, ZIP_STORED, ZIP_DEFLATED +from contextlib import closing + +from standalone.__init__ import print_opt, print_std_usage + +iswindows = sys.platform.startswith('win') +isosx = sys.platform.startswith('darwin') + +def print_removedrm_help(): + from __init__ import PLUGIN_NAME, PLUGIN_VERSION + print(PLUGIN_NAME + " v" + PLUGIN_VERSION + " - Calibre DRM removal plugin by noDRM") + print() + print("remove_drm: Remove DRM from one or multiple files") + print() + print_std_usage("remove_drm", " ... [ -o ] [ -f ]") + + print() + print("Options: ") + print_opt(None, "outputdir", "Folder to export the file(s) to") + print_opt("o", "output", "File name to export the file to") + print_opt("f", "force", "Overwrite output file if it already exists") + print_opt(None, "overwrite", "Replace DRMed file with DRM-free file (implies --force)") + + +def determine_file_type(file): + # Returns a file type: + # "PDF", "PDB", "MOBI", "TPZ", "LCP", "ADEPT", "ADEPT-PassHash", "KFX-ZIP", "ZIP" or None + + f = open(file, "rb") + fdata = f.read(100) + f.close() + + if fdata.startswith(b"PK\x03\x04"): + pass + # Either LCP, Adobe, or Amazon + elif fdata.startswith(b"%PDF"): + return "PDF" + elif fdata[0x3c:0x3c+8] == b"PNRdPPrs" or fdata[0x3c:0x3c+8] == b"PDctPPrs": + return "PDB" + elif fdata[0x3c:0x3c+8] == b"BOOKMOBI" or fdata[0x3c:0x3c+8] == b"TEXtREAd": + return "MOBI" + elif fdata.startswith(b"TPZ"): + return "TPZ" + else: + return None + # Unknown file type + + + # If it's a ZIP, determine the type. + + from lcpdedrm import isLCPbook + if isLCPbook(file): + return "LCP" + + from ineptepub import adeptBook, isPassHashBook + if adeptBook(file): + if isPassHashBook(file): + return "ADEPT-PassHash" + else: + return "ADEPT" + + try: + # Amazon / KFX-ZIP has a file that starts with b'\xeaDRMION\xee' in the ZIP. + with closing(ZipFile(open(file, "rb"))) as book: + for subfilename in book.namelist(): + with book.open(subfilename) as subfile: + data = subfile.read(8) + if data == b'\xeaDRMION\xee': + return "KFX-ZIP" + except: + pass + + return "ZIP" + + + + +def dedrm_single_file(input_file, output_file): + # When this runs, all the stupid file handling is done. + # Just take the file at the absolute path "input_file" + # and export it, DRM-free, to "output_file". + + # Use a temp file as input_file and output_file + # might be identical. + + # The output directory might not exist yet. + + print("File " + input_file + " to " + output_file) + + # Okay, first check the file type and don't rely on the extension. + try: + ftype = determine_file_type(input_file) + except: + print("Can't determine file type for this file.") + ftype = None + + if ftype is None: + return + + + + + +def perform_action(params, files): + output = None + outputdir = None + force = False + overwrite_original = False + + + if len(files) == 0: + print_removedrm_help() + return 0 + + while len(params) > 0: + p = params.pop(0) + if p == "--output": + output = params.pop(0) + elif p == "--outputdir": + outputdir = params.pop(0) + elif p == "--force": + force = True + elif p == "--overwrite": + overwrite_original = True + force = True + elif p == "--help": + print_removedrm_help() + return 0 + + if overwrite_original and (output is not None or outputdir is not None): + print("Can't use --overwrite together with --output or --outputdir.") + return 1 + + if output is not None and os.path.isfile(output) and not force: + print("Output file already exists. Use --force to overwrite.", file=sys.stderr) + return 1 + + + if output is not None and len(files) > 1: + print("Cannot set output file name if there's multiple input files.", file=sys.stderr) + return 1 + + if outputdir is not None and output is not None and os.path.isabs(output): + print("--output parameter is absolute path despite --outputdir being set.") + print("Remove --outputdir, or give a relative path to --output.") + return 1 + + + + for file in files: + + file = os.path.abspath(file) + + if not os.path.isfile(file): + print("Skipping file " + file + " - not found.") + continue + + if overwrite_original: + output_filename = file + else: + if output is not None: + # Due to the check above, we DO only have one file here. + if outputdir is not None and not os.path.isabs(output): + output_filename = os.path.join(outputdir, output) + else: + output_filename = os.path.abspath(output) + else: + if outputdir is None: + outputdir = os.getcwd() + output_filename = os.path.join(outputdir, os.path.basename(file)) + output_filename = os.path.abspath(output_filename) + + if output_filename == file: + # If we export to the import folder, add a suffix to the file name. + fn, f_ext = os.path.splitext(output_filename) + output_filename = fn + "_nodrm" + f_ext + + + + if os.path.isfile(output_filename) and not force: + print("Skipping file " + file + " because output file already exists (use --force).", file=sys.stderr) + continue + + + + dedrm_single_file(file, output_filename) + + + + + return 0 + + +if __name__ == "__main__": + print("This code is not intended to be executed directly!", file=sys.stderr) \ No newline at end of file