#!/usr/bin/env python
# -*- coding: utf-8 -*-

from __future__ import with_statement

# k4mobidedrm.py
# Copyright © 2008-2019 by Apprentice Harper et al.

__license__ = 'GPL v3'
__version__ = '5.7'

# Engine to remove drm from Kindle and Mobipocket ebooks
# for personal use for archiving and converting your ebooks

# PLEASE DO NOT PIRATE EBOOKS!

# We want all authors and publishers, and ebook stores to live
# long and prosperous lives but at the same time  we just want to
# be able to read OUR books on whatever device we want and to keep
# readable for a long, long time

# This borrows very heavily from works by CMBDTC, IHeartCabbages, skindle,
#    unswindle, DarkReverser, ApprenticeAlf, and many many others

# Special thanks to The Dark Reverser for MobiDeDrm and CMBDTC for cmbdtc_dump
# from which this script borrows most unashamedly.

# Changelog
#  1.0 - Name change to k4mobidedrm. Adds Mac support, Adds plugin code
#  1.1 - Adds support for additional kindle.info files
#  1.2 - Better error handling for older Mobipocket
#  1.3 - Don't try to decrypt Topaz books
#  1.7 - Add support for Topaz books and Kindle serial numbers. Split code.
#  1.9 - Tidy up after Topaz, minor exception changes
#  2.1 - Topaz fix and filename sanitizing
#  2.2 - Topaz Fix and minor Mac code fix
#  2.3 - More Topaz fixes
#  2.4 - K4PC/Mac key generation fix
#  2.6 - Better handling of non-K4PC/Mac ebooks
#  2.7 - Better trailing bytes handling in mobidedrm
#  2.8 - Moved parsing of kindle.info files to mac & pc util files.
#  3.1 - Updated for new calibre interface. Now __init__ in plugin.
#  3.5 - Now support Kindle for PC/Mac 1.6
#  3.6 - Even better trailing bytes handling in mobidedrm
#  3.7 - Add support for Amazon Print Replica ebooks.
#  3.8 - Improved Topaz support
#  4.1 - Improved Topaz support and faster decryption with alfcrypto
#  4.2 - Added support for Amazon's KF8 format ebooks
#  4.4 - Linux calls to Wine added, and improved configuration dialog
#  4.5 - Linux works again without Wine. Some Mac key file search changes
#  4.6 - First attempt to handle unicode properly
#  4.7 - Added timing reports, and changed search for Mac key files
#  4.8 - Much better unicode handling, matching the updated inept and ignoble scripts
#      - Moved back into plugin, __init__ in plugin now only contains plugin code.
#  4.9 - Missed some invalid characters in cleanup_name
#  5.0 - Extraction of info from Kindle for PC/Mac moved into kindlekey.py
#      - tweaked GetDecryptedBook interface to leave passed parameters unchanged
#  5.1 - moved unicode_argv call inside main for Windows DeDRM compatibility
#  5.2 - Fixed error in command line processing of unicode arguments
#  5.3 - Changed Android support to allow passing of backup .ab files
#  5.4 - Recognise KFX files masquerading as azw, even if we can't decrypt them yet.
#  5.5 - Added GPL v3 licence explicitly.
#  5.6 - Invoke KFXZipBook to handle zipped KFX files
#  5.7 - Revamp cleanup_name

import sys, os, re
import csv
import getopt
import re
import traceback
import time
import htmlentitydefs
import json

class DrmException(Exception):
    pass

if 'calibre' in sys.modules:
    inCalibre = True
else:
    inCalibre = False

if inCalibre:
    from calibre_plugins.dedrm import mobidedrm
    from calibre_plugins.dedrm import topazextract
    from calibre_plugins.dedrm import kgenpids
    from calibre_plugins.dedrm import androidkindlekey
    from calibre_plugins.dedrm import kfxdedrm
else:
    import mobidedrm
    import topazextract
    import kgenpids
    import androidkindlekey
    import kfxdedrm

# Wrap a stream so that output gets flushed immediately
# and also make sure that any unicode strings get
# encoded using "replace" before writing them.
class SafeUnbuffered:
    def __init__(self, stream):
        self.stream = stream
        self.encoding = stream.encoding
        if self.encoding == None:
            self.encoding = "utf-8"
    def write(self, data):
        if isinstance(data,unicode):
            data = data.encode(self.encoding,"replace")
        self.stream.write(data)
        self.stream.flush()
    def __getattr__(self, attr):
        return getattr(self.stream, attr)

iswindows = sys.platform.startswith('win')
isosx = sys.platform.startswith('darwin')

def unicode_argv():
    if iswindows:
        # Uses shell32.GetCommandLineArgvW to get sys.argv as a list of Unicode
        # strings.

        # Versions 2.x of Python don't support Unicode in sys.argv on
        # Windows, with the underlying Windows API instead replacing multi-byte
        # characters with '?'.


        from ctypes import POINTER, byref, cdll, c_int, windll
        from ctypes.wintypes import LPCWSTR, LPWSTR

        GetCommandLineW = cdll.kernel32.GetCommandLineW
        GetCommandLineW.argtypes = []
        GetCommandLineW.restype = LPCWSTR

        CommandLineToArgvW = windll.shell32.CommandLineToArgvW
        CommandLineToArgvW.argtypes = [LPCWSTR, POINTER(c_int)]
        CommandLineToArgvW.restype = POINTER(LPWSTR)

        cmd = GetCommandLineW()
        argc = c_int(0)
        argv = CommandLineToArgvW(cmd, byref(argc))
        if argc.value > 0:
            # Remove Python executable and commands if present
            start = argc.value - len(sys.argv)
            return [argv[i] for i in
                    xrange(start, argc.value)]
        # if we don't have any arguments at all, just pass back script name
        # this should never happen
        return [u"mobidedrm.py"]
    else:
        argvencoding = sys.stdin.encoding
        if argvencoding == None:
            argvencoding = "utf-8"
        return [arg if (type(arg) == unicode) else unicode(arg,argvencoding) for arg in sys.argv]

# cleanup unicode filenames
# borrowed from calibre from calibre/src/calibre/__init__.py
# added in removal of control (<32) chars
# and removal of . at start and end
# and with some (heavily edited) code from Paul Durrant's kindlenamer.py
# and some improvements suggested by jhaisley
def cleanup_name(name):
    # substitute filename unfriendly characters
    name = name.replace(u"<",u"[").replace(u">",u"]").replace(u" : ",u" – ").replace(u": ",u" – ").replace(u":",u"—").replace(u"/",u"_").replace(u"\\",u"_").replace(u"|",u"_").replace(u"\"",u"\'").replace(u"*",u"_").replace(u"?",u"")
    # white space to single space, delete leading and trailing while space
    name = re.sub(ur"\s", u" ", name).strip()
    # delete control characters
    name = u"".join(char for char in name if ord(char)>=32)
    # delete non-ascii characters
    name = u"".join(char for char in name if ord(char)<=126)
    # remove leading dots
    while len(name)>0 and name[0] == u".":
        name = name[1:]
    # remove trailing dots (Windows doesn't like them)
    while name.endswith(u'.'):
        name = name[:-1]
    if len(name)==0:
        name=u"DecryptedBook"
    return name

# must be passed unicode
def unescape(text):
    def fixup(m):
        text = m.group(0)
        if text[:2] == u"&#":
            # character reference
            try:
                if text[:3] == u"&#x":
                    return unichr(int(text[3:-1], 16))
                else:
                    return unichr(int(text[2:-1]))
            except ValueError:
                pass
        else:
            # named entity
            try:
                text = unichr(htmlentitydefs.name2codepoint[text[1:-1]])
            except KeyError:
                pass
        return text # leave as is
    return re.sub(u"&#?\w+;", fixup, text)

def GetDecryptedBook(infile, kDatabases, androidFiles, serials, pids, starttime = time.time()):
    # handle the obvious cases at the beginning
    if not os.path.isfile(infile):
        raise DrmException(u"Input file does not exist.")

    mobi = True
    magic8 = open(infile,'rb').read(8)
    if magic8 == '\xeaDRMION\xee':
        raise DrmException(u"The .kfx DRMION file cannot be decrypted by itself. A .kfx-zip archive containing a DRM voucher is required.")

    magic3 = magic8[:3]
    if magic3 == 'TPZ':
        mobi = False

    if magic8[:4] == 'PK\x03\x04':
        mb = kfxdedrm.KFXZipBook(infile)
    elif mobi:
        mb = mobidedrm.MobiBook(infile)
    else:
        mb = topazextract.TopazBook(infile)

    bookname = unescape(mb.getBookTitle())
    print u"Decrypting {1} ebook: {0}".format(bookname, mb.getBookType())

    # copy list of pids
    totalpids = list(pids)
    # extend list of serials with serials from android databases
    for aFile in androidFiles:
        serials.extend(androidkindlekey.get_serials(aFile))
    # extend PID list with book-specific PIDs from seriala and kDatabases
    md1, md2 = mb.getPIDMetaInfo()
    totalpids.extend(kgenpids.getPidList(md1, md2, serials, kDatabases))
    # remove any duplicates
    totalpids = list(set(totalpids))
    print u"Found {1:d} keys to try after {0:.1f} seconds".format(time.time()-starttime, len(totalpids))
    #print totalpids

    try:
        mb.processBook(totalpids)
    except:
        mb.cleanup
        raise

    print u"Decryption succeeded after {0:.1f} seconds".format(time.time()-starttime)
    return mb


# kDatabaseFiles is a list of files created by kindlekey
def decryptBook(infile, outdir, kDatabaseFiles, androidFiles, serials, pids):
    starttime = time.time()
    kDatabases = []
    for dbfile in kDatabaseFiles:
        kindleDatabase = {}
        try:
            with open(dbfile, 'r') as keyfilein:
                kindleDatabase = json.loads(keyfilein.read())
            kDatabases.append([dbfile,kindleDatabase])
        except Exception, e:
            print u"Error getting database from file {0:s}: {1:s}".format(dbfile,e)
            traceback.print_exc()



    try:
        book = GetDecryptedBook(infile, kDatabases, androidFiles, serials, pids, starttime)
    except Exception, e:
        print u"Error decrypting book after {1:.1f} seconds: {0}".format(e.args[0],time.time()-starttime)
        traceback.print_exc()
        return 1

    # Try to infer a reasonable name
    orig_fn_root = os.path.splitext(os.path.basename(infile))[0]
    if (
        re.match('^B[A-Z0-9]{9}(_EBOK|_EBSP|_sample)?$', orig_fn_root) or
        re.match('^{0-9A-F-}{36}$', orig_fn_root)
    ):  # Kindle for PC / Mac / Android / Fire / iOS
        clean_title = cleanup_name(book.getBookTitle())
        outfilename = u'{}_{}'.format(orig_fn_root, clean_title)
    else:  # E Ink Kindle, which already uses a reasonable name
        outfilename = orig_fn_root

    # avoid excessively long file names
    if len(outfilename)>150:
        outfilename = outfilename[:99]+"--"+outfilename[-49:]

    outfilename = outfilename+u"_nodrm"
    outfile = os.path.join(outdir, outfilename + book.getBookExtension())

    book.getFile(outfile)
    print u"Saved decrypted book {1:s} after {0:.1f} seconds".format(time.time()-starttime, outfilename)

    if book.getBookType()==u"Topaz":
        zipname = os.path.join(outdir, outfilename + u"_SVG.zip")
        book.getSVGZip(zipname)
        print u"Saved SVG ZIP Archive for {1:s} after {0:.1f} seconds".format(time.time()-starttime, outfilename)

    # remove internal temporary directory of Topaz pieces
    book.cleanup()
    return 0


def usage(progname):
    print u"Removes DRM protection from Mobipocket, Amazon KF8, Amazon Print Replica and Amazon Topaz ebooks"
    print u"Usage:"
    print u"    {0} [-k <kindle.k4i>] [-p <comma separated PIDs>] [-s <comma separated Kindle serial numbers>] [ -a <AmazonSecureStorage.xml|backup.ab> ] <infile> <outdir>".format(progname)

#
# Main
#
def cli_main():
    argv=unicode_argv()
    progname = os.path.basename(argv[0])
    print u"K4MobiDeDrm v{0}.\nCopyright © 2008-2017 Apprentice Harper et al.".format(__version__)

    try:
        opts, args = getopt.getopt(argv[1:], "k:p:s:a:")
    except getopt.GetoptError, err:
        print u"Error in options or arguments: {0}".format(err.args[0])
        usage(progname)
        sys.exit(2)
    if len(args)<2:
        usage(progname)
        sys.exit(2)

    infile = args[0]
    outdir = args[1]
    kDatabaseFiles = []
    androidFiles = []
    serials = []
    pids = []

    for o, a in opts:
        if o == "-k":
            if a == None :
                raise DrmException("Invalid parameter for -k")
            kDatabaseFiles.append(a)
        if o == "-p":
            if a == None :
                raise DrmException("Invalid parameter for -p")
            pids = a.split(',')
        if o == "-s":
            if a == None :
                raise DrmException("Invalid parameter for -s")
            serials = a.split(',')
        if o == '-a':
            if a == None:
                raise DrmException("Invalid parameter for -a")
            androidFiles.append(a)

    # try with built in Kindle Info files if not on Linux
    k4 = not sys.platform.startswith('linux')

    return decryptBook(infile, outdir, kDatabaseFiles, androidFiles, serials, pids)


if __name__ == '__main__':
    sys.stdout=SafeUnbuffered(sys.stdout)
    sys.stderr=SafeUnbuffered(sys.stderr)
    sys.exit(cli_main())
