chiark / gitweb /
Move index related methods to new index module
[fdroidserver.git] / fdroidserver / update.py
index 436edf1d14686d4d8d6ec7f27704f67121d3ee85..a68f1ca4bae8f79a35ab9495ab38bff0cccc261e 100644 (file)
@@ -19,7 +19,6 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-import copy
 import sys
 import os
 import shutil
@@ -31,25 +30,22 @@ import zipfile
 import hashlib
 import pickle
 import platform
-import urllib.parse
 from datetime import datetime, timedelta
-from xml.dom.minidom import Document
 from argparse import ArgumentParser
 
 import collections
 from pyasn1.error import PyAsn1Error
 from pyasn1.codec.der import decoder, encoder
 from pyasn1_modules import rfc2315
-from binascii import hexlify, unhexlify
+from binascii import hexlify
 
 from PIL import Image
 import logging
 
-from . import signindex
 from . import common
+from . import index
 from . import metadata
-from .common import FDroidPopen, FDroidPopenBytes, SdkToolsPopen
-from .metadata import MetaDataException
+from .common import FDroidPopen, SdkToolsPopen
 
 METADATA_VERSION = 18
 
@@ -1105,31 +1101,6 @@ def scan_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
     return apks, cachechanged
 
 
-repo_pubkey_fingerprint = None
-
-
-def extract_pubkey():
-    global repo_pubkey_fingerprint
-    if 'repo_pubkey' in config:
-        pubkey = unhexlify(config['repo_pubkey'])
-    else:
-        p = FDroidPopenBytes([config['keytool'], '-exportcert',
-                              '-alias', config['repo_keyalias'],
-                              '-keystore', config['keystore'],
-                              '-storepass:file', config['keystorepassfile']]
-                             + config['smartcardoptions'],
-                             output=False, stderr_to_stdout=False)
-        if p.returncode != 0 or len(p.output) < 20:
-            msg = "Failed to get repo pubkey!"
-            if config['keystore'] == 'NONE':
-                msg += ' Is your crypto smartcard plugged in?'
-            logging.critical(msg)
-            sys.exit(1)
-        pubkey = p.output
-    repo_pubkey_fingerprint = common.get_cert_fingerprint(pubkey)
-    return hexlify(pubkey)
-
-
 def apply_info_from_latest_apk(apps, apks):
     """
     Some information from the apks needs to be applied up to the application level.
@@ -1169,474 +1140,6 @@ def apply_info_from_latest_apk(apps, apks):
                 app.CurrentVersionCode = str(bestver)
 
 
-# Get raw URL from git service for mirroring
-def get_raw_mirror(url):
-    # Divide urls in parts
-    url = url.split("/")
-
-    # Get the hostname
-    hostname = url[2]
-
-    # fdroidserver will use always 'master' branch for git-mirroring
-    branch = "master"
-    folder = "fdroid"
-
-    if hostname == "github.com":
-        # Github like RAW url "https://raw.githubusercontent.com/user/repo/master/fdroid"
-        url[2] = "raw.githubusercontent.com"
-        url.extend([branch, folder])
-    elif hostname == "gitlab.com":
-        # Gitlab like RAW url "https://gitlab.com/user/repo/raw/master/fdroid"
-        url.extend(["raw", branch, folder])
-    else:
-        return None
-
-    url = "/".join(url)
-    return url
-
-
-def make_index(apps, sortedids, apks, repodir, archive):
-    """Generate the repo index files.
-
-    :param apps: fully populated apps list
-    :param apks: full populated apks list
-    :param repodir: the repo directory
-    :param archive: True if this is the archive repo, False if it's the
-                    main one.
-    :param categories: list of categories
-    """
-
-    def _resolve_description_link(appid):
-        if appid in apps:
-            return ("fdroid.app:" + appid, apps[appid].Name)
-        raise MetaDataException("Cannot resolve app id " + appid)
-
-    nosigningkey = False
-    if not options.nosign:
-        if 'repo_keyalias' not in config:
-            nosigningkey = True
-            logging.critical("'repo_keyalias' not found in config.py!")
-        if 'keystore' not in config:
-            nosigningkey = True
-            logging.critical("'keystore' not found in config.py!")
-        if 'keystorepass' not in config and 'keystorepassfile' not in config:
-            nosigningkey = True
-            logging.critical("'keystorepass' not found in config.py!")
-        if 'keypass' not in config and 'keypassfile' not in config:
-            nosigningkey = True
-            logging.critical("'keypass' not found in config.py!")
-        if not os.path.exists(config['keystore']):
-            nosigningkey = True
-            logging.critical("'" + config['keystore'] + "' does not exist!")
-        if nosigningkey:
-            logging.warning("`fdroid update` requires a signing key, you can create one using:")
-            logging.warning("\tfdroid update --create-key")
-            sys.exit(1)
-
-    repodict = collections.OrderedDict()
-    repodict['timestamp'] = datetime.utcnow()
-    repodict['version'] = METADATA_VERSION
-
-    if config['repo_maxage'] != 0:
-        repodict['maxage'] = config['repo_maxage']
-
-    if archive:
-        repodict['name'] = config['archive_name']
-        repodict['icon'] = os.path.basename(config['archive_icon'])
-        repodict['address'] = config['archive_url']
-        repodict['description'] = config['archive_description']
-        urlbasepath = os.path.basename(urllib.parse.urlparse(config['archive_url']).path)
-    else:
-        repodict['name'] = config['repo_name']
-        repodict['icon'] = os.path.basename(config['repo_icon'])
-        repodict['address'] = config['repo_url']
-        repodict['description'] = config['repo_description']
-        urlbasepath = os.path.basename(urllib.parse.urlparse(config['repo_url']).path)
-
-    mirrorcheckfailed = False
-    mirrors = []
-    for mirror in sorted(config.get('mirrors', [])):
-        base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
-        if config.get('nonstandardwebroot') is not True and base != 'fdroid':
-            logging.error("mirror '" + mirror + "' does not end with 'fdroid'!")
-            mirrorcheckfailed = True
-        # must end with / or urljoin strips a whole path segment
-        if mirror.endswith('/'):
-            mirrors.append(urllib.parse.urljoin(mirror, urlbasepath))
-        else:
-            mirrors.append(urllib.parse.urljoin(mirror + '/', urlbasepath))
-    for mirror in config.get('servergitmirrors', []):
-        mirror = get_raw_mirror(mirror)
-        if mirror is not None:
-            mirrors.append(mirror + '/')
-    if mirrorcheckfailed:
-        sys.exit(1)
-    if mirrors:
-        repodict['mirrors'] = mirrors
-
-    appsWithPackages = collections.OrderedDict()
-    for packageName in sortedids:
-        app = apps[packageName]
-        if app['Disabled']:
-            continue
-
-        # only include apps with packages
-        for apk in apks:
-            if apk['packageName'] == packageName:
-                newapp = copy.copy(app)  # update wiki needs unmodified description
-                newapp['Description'] = metadata.description_html(app['Description'],
-                                                                  _resolve_description_link)
-                appsWithPackages[packageName] = newapp
-                break
-
-    requestsdict = dict()
-    for command in ('install', 'uninstall'):
-        packageNames = []
-        key = command + '_list'
-        if key in config:
-            if isinstance(config[key], str):
-                packageNames = [config[key]]
-            elif all(isinstance(item, str) for item in config[key]):
-                packageNames = config[key]
-            else:
-                raise TypeError('only accepts strings, lists, and tuples')
-        requestsdict[command] = packageNames
-
-    make_index_v0(appsWithPackages, apks, repodir, repodict, requestsdict)
-    make_index_v1(appsWithPackages, apks, repodir, repodict, requestsdict)
-
-
-def make_index_v1(apps, packages, repodir, repodict, requestsdict):
-
-    def _index_encoder_default(obj):
-        if isinstance(obj, set):
-            return list(obj)
-        if isinstance(obj, datetime):
-            return int(obj.timestamp() * 1000)  # Java expects milliseconds
-        raise TypeError(repr(obj) + " is not JSON serializable")
-
-    output = collections.OrderedDict()
-    output['repo'] = repodict
-    output['requests'] = requestsdict
-
-    appslist = []
-    output['apps'] = appslist
-    for appid, appdict in apps.items():
-        d = collections.OrderedDict()
-        appslist.append(d)
-        for k, v in sorted(appdict.items()):
-            if not v:
-                continue
-            if k in ('builds', 'comments', 'metadatapath',
-                     'ArchivePolicy', 'AutoUpdateMode', 'MaintainerNotes',
-                     'Provides', 'Repo', 'RepoType', 'RequiresRoot',
-                     'UpdateCheckData', 'UpdateCheckIgnore', 'UpdateCheckMode',
-                     'UpdateCheckName', 'NoSourceSince', 'VercodeOperation'):
-                continue
-
-            # name things after the App class fields in fdroidclient
-            if k == 'id':
-                k = 'packageName'
-            elif k == 'CurrentVersionCode':  # TODO make SuggestedVersionCode the canonical name
-                k = 'suggestedVersionCode'
-            elif k == 'CurrentVersion':  # TODO make SuggestedVersionName the canonical name
-                k = 'suggestedVersionName'
-            elif k == 'AutoName':
-                if 'Name' not in apps[appid]:
-                    d['name'] = v
-                continue
-            else:
-                k = k[:1].lower() + k[1:]
-            d[k] = v
-
-    output_packages = dict()
-    output['packages'] = output_packages
-    for package in packages:
-        packageName = package['packageName']
-        if packageName in output_packages:
-            packagelist = output_packages[packageName]
-        else:
-            packagelist = []
-            output_packages[packageName] = packagelist
-        d = collections.OrderedDict()
-        packagelist.append(d)
-        for k, v in sorted(package.items()):
-            if not v:
-                continue
-            if k in ('icon', 'icons', 'icons_src', 'name', ):
-                continue
-            d[k] = v
-
-    json_name = 'index-v1.json'
-    index_file = os.path.join(repodir, json_name)
-    with open(index_file, 'w') as fp:
-        json.dump(output, fp, default=_index_encoder_default)
-
-    if options.nosign:
-        logging.debug('index-v1 must have a signature, use `fdroid signindex` to create it!')
-    else:
-        signindex.config = config
-        signindex.sign_index_v1(repodir, json_name)
-
-
-def make_index_v0(apps, apks, repodir, repodict, requestsdict):
-    '''aka index.jar aka index.xml'''
-
-    doc = Document()
-
-    def addElement(name, value, doc, parent):
-        el = doc.createElement(name)
-        el.appendChild(doc.createTextNode(value))
-        parent.appendChild(el)
-
-    def addElementNonEmpty(name, value, doc, parent):
-        if not value:
-            return
-        addElement(name, value, doc, parent)
-
-    def addElementIfInApk(name, apk, key, doc, parent):
-        if key not in apk:
-            return
-        value = str(apk[key])
-        addElement(name, value, doc, parent)
-
-    def addElementCDATA(name, value, doc, parent):
-        el = doc.createElement(name)
-        el.appendChild(doc.createCDATASection(value))
-        parent.appendChild(el)
-
-    root = doc.createElement("fdroid")
-    doc.appendChild(root)
-
-    repoel = doc.createElement("repo")
-
-    repoel.setAttribute("name", repodict['name'])
-    if 'maxage' in repodict:
-        repoel.setAttribute("maxage", str(repodict['maxage']))
-    repoel.setAttribute("icon", os.path.basename(repodict['icon']))
-    repoel.setAttribute("url", repodict['address'])
-    addElement('description', repodict['description'], doc, repoel)
-    for mirror in repodict.get('mirrors', []):
-        addElement('mirror', mirror, doc, repoel)
-
-    repoel.setAttribute("version", str(repodict['version']))
-    repoel.setAttribute("timestamp", '%d' % repodict['timestamp'].timestamp())
-
-    repoel.setAttribute("pubkey", extract_pubkey().decode('utf-8'))
-    root.appendChild(repoel)
-
-    for command in ('install', 'uninstall'):
-        for packageName in requestsdict[command]:
-            element = doc.createElement(command)
-            root.appendChild(element)
-            element.setAttribute('packageName', packageName)
-
-    for appid, appdict in apps.items():
-        app = metadata.App(appdict)
-
-        if app.Disabled is not None:
-            continue
-
-        # Get a list of the apks for this app...
-        apklist = []
-        for apk in apks:
-            if apk['packageName'] == appid:
-                apklist.append(apk)
-
-        if len(apklist) == 0:
-            continue
-
-        apel = doc.createElement("application")
-        apel.setAttribute("id", app.id)
-        root.appendChild(apel)
-
-        addElement('id', app.id, doc, apel)
-        if app.added:
-            addElement('added', app.added.strftime('%Y-%m-%d'), doc, apel)
-        if app.lastUpdated:
-            addElement('lastupdated', app.lastUpdated.strftime('%Y-%m-%d'), doc, apel)
-        addElement('name', app.Name, doc, apel)
-        addElement('summary', app.Summary, doc, apel)
-        if app.icon:
-            addElement('icon', app.icon, doc, apel)
-
-        if app.get('Description'):
-            description = app.Description
-        else:
-            description = '<p>No description available</p>'
-        addElement('desc', description, doc, apel)
-        addElement('license', app.License, doc, apel)
-        if app.Categories:
-            addElement('categories', ','.join(app.Categories), doc, apel)
-            # We put the first (primary) category in LAST, which will have
-            # the desired effect of making clients that only understand one
-            # category see that one.
-            addElement('category', app.Categories[0], doc, apel)
-        addElement('web', app.WebSite, doc, apel)
-        addElement('source', app.SourceCode, doc, apel)
-        addElement('tracker', app.IssueTracker, doc, apel)
-        addElementNonEmpty('changelog', app.Changelog, doc, apel)
-        addElementNonEmpty('author', app.AuthorName, doc, apel)
-        addElementNonEmpty('email', app.AuthorEmail, doc, apel)
-        addElementNonEmpty('donate', app.Donate, doc, apel)
-        addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
-        addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
-        addElementNonEmpty('flattr', app.FlattrID, doc, apel)
-
-        # These elements actually refer to the current version (i.e. which
-        # one is recommended. They are historically mis-named, and need
-        # changing, but stay like this for now to support existing clients.
-        addElement('marketversion', app.CurrentVersion, doc, apel)
-        addElement('marketvercode', app.CurrentVersionCode, doc, apel)
-
-        if app.Provides:
-            pv = app.Provides.split(',')
-            addElementNonEmpty('provides', ','.join(pv), doc, apel)
-        if app.RequiresRoot:
-            addElement('requirements', 'root', doc, apel)
-
-        # Sort the apk list into version order, just so the web site
-        # doesn't have to do any work by default...
-        apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
-
-        if 'antiFeatures' in apklist[0]:
-            app.AntiFeatures.extend(apklist[0]['antiFeatures'])
-        if app.AntiFeatures:
-            addElementNonEmpty('antifeatures', ','.join(app.AntiFeatures), doc, apel)
-
-        # Check for duplicates - they will make the client unhappy...
-        for i in range(len(apklist) - 1):
-            if apklist[i]['versionCode'] == apklist[i + 1]['versionCode']:
-                logging.critical("duplicate versions: '%s' - '%s'" % (
-                    apklist[i]['apkName'], apklist[i + 1]['apkName']))
-                sys.exit(1)
-
-        current_version_code = 0
-        current_version_file = None
-        for apk in apklist:
-            file_extension = common.get_file_extension(apk['apkName'])
-            # find the APK for the "Current Version"
-            if current_version_code < apk['versionCode']:
-                current_version_code = apk['versionCode']
-            if current_version_code < int(app.CurrentVersionCode):
-                current_version_file = apk['apkName']
-
-            apkel = doc.createElement("package")
-            apel.appendChild(apkel)
-            addElement('version', apk['versionName'], doc, apkel)
-            addElement('versioncode', str(apk['versionCode']), doc, apkel)
-            addElement('apkname', apk['apkName'], doc, apkel)
-            addElementIfInApk('srcname', apk, 'srcname', doc, apkel)
-
-            hashel = doc.createElement("hash")
-            hashel.setAttribute('type', 'sha256')
-            hashel.appendChild(doc.createTextNode(apk['hash']))
-            apkel.appendChild(hashel)
-
-            addElement('size', str(apk['size']), doc, apkel)
-            addElementIfInApk('sdkver', apk,
-                              'minSdkVersion', doc, apkel)
-            addElementIfInApk('targetSdkVersion', apk,
-                              'targetSdkVersion', doc, apkel)
-            addElementIfInApk('maxsdkver', apk,
-                              'maxSdkVersion', doc, apkel)
-            addElementIfInApk('obbMainFile', apk,
-                              'obbMainFile', doc, apkel)
-            addElementIfInApk('obbMainFileSha256', apk,
-                              'obbMainFileSha256', doc, apkel)
-            addElementIfInApk('obbPatchFile', apk,
-                              'obbPatchFile', doc, apkel)
-            addElementIfInApk('obbPatchFileSha256', apk,
-                              'obbPatchFileSha256', doc, apkel)
-            if 'added' in apk:
-                addElement('added', apk['added'].strftime('%Y-%m-%d'), doc, apkel)
-
-            if file_extension == 'apk':  # sig is required for APKs, but only APKs
-                addElement('sig', apk['sig'], doc, apkel)
-
-                old_permissions = set()
-                sorted_permissions = sorted(apk['uses-permission'])
-                for perm in sorted_permissions:
-                    perm_name = perm.name
-                    if perm_name.startswith("android.permission."):
-                        perm_name = perm_name[19:]
-                    old_permissions.add(perm_name)
-                addElementNonEmpty('permissions', ','.join(old_permissions), doc, apkel)
-
-                for permission in sorted_permissions:
-                    permel = doc.createElement('uses-permission')
-                    permel.setAttribute('name', permission.name)
-                    if permission.maxSdkVersion is not None:
-                        permel.setAttribute('maxSdkVersion', '%d' % permission.maxSdkVersion)
-                        apkel.appendChild(permel)
-                for permission_sdk_23 in sorted(apk['uses-permission-sdk-23']):
-                    permel = doc.createElement('uses-permission-sdk-23')
-                    permel.setAttribute('name', permission_sdk_23.name)
-                    if permission_sdk_23.maxSdkVersion is not None:
-                        permel.setAttribute('maxSdkVersion', '%d' % permission_sdk_23.maxSdkVersion)
-                        apkel.appendChild(permel)
-                if 'nativecode' in apk:
-                    addElement('nativecode', ','.join(sorted(apk['nativecode'])), doc, apkel)
-                addElementNonEmpty('features', ','.join(sorted(apk['features'])), doc, apkel)
-
-        if current_version_file is not None \
-                and config['make_current_version_link'] \
-                and repodir == 'repo':  # only create these
-            namefield = config['current_version_name_source']
-            sanitized_name = re.sub('''[ '"&%?+=/]''', '', app.get(namefield))
-            apklinkname = sanitized_name + '.apk'
-            current_version_path = os.path.join(repodir, current_version_file)
-            if os.path.islink(apklinkname):
-                os.remove(apklinkname)
-            os.symlink(current_version_path, apklinkname)
-            # also symlink gpg signature, if it exists
-            for extension in ('.asc', '.sig'):
-                sigfile_path = current_version_path + extension
-                if os.path.exists(sigfile_path):
-                    siglinkname = apklinkname + extension
-                    if os.path.islink(siglinkname):
-                        os.remove(siglinkname)
-                    os.symlink(sigfile_path, siglinkname)
-
-    if options.pretty:
-        output = doc.toprettyxml(encoding='utf-8')
-    else:
-        output = doc.toxml(encoding='utf-8')
-
-    with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
-        f.write(output)
-
-    if 'repo_keyalias' in config:
-
-        if options.nosign:
-            logging.info("Creating unsigned index in preparation for signing")
-        else:
-            logging.info("Creating signed index with this key (SHA256):")
-            logging.info("%s" % repo_pubkey_fingerprint)
-
-        # Create a jar of the index...
-        jar_output = 'index_unsigned.jar' if options.nosign else 'index.jar'
-        p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
-        if p.returncode != 0:
-            logging.critical("Failed to create {0}".format(jar_output))
-            sys.exit(1)
-
-        # Sign the index...
-        signed = os.path.join(repodir, 'index.jar')
-        if options.nosign:
-            # Remove old signed index if not signing
-            if os.path.exists(signed):
-                os.remove(signed)
-        else:
-            signindex.config = config
-            signindex.sign_jar(signed)
-
-    # Copy the repo icon into the repo directory...
-    icon_dir = os.path.join(repodir, 'icons')
-    iconfilename = os.path.join(icon_dir, os.path.basename(config['repo_icon']))
-    shutil.copyfile(config['repo_icon'], iconfilename)
-
-
 def make_categories_txt(repodir, categories):
     '''Write a category list in the repo to allow quick access'''
     catdata = ''
@@ -1978,6 +1481,10 @@ def main():
     # name comes from there!)
     sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
 
+    # pass options and config for repo index handling
+    index.options = options
+    index.config = config
+
     # APKs are placed into multiple repos based on the app package, providing
     # per-app subscription feeds for nightly builds and things like it
     if config['per_app_repos']:
@@ -1987,7 +1494,7 @@ def main():
             appdict = dict()
             appdict[appid] = app
             if os.path.isdir(repodir):
-                make_index(appdict, [appid], apks, repodir, False)
+                index.make(appdict, [appid], apks, repodir, False)
             else:
                 logging.info('Skipping index generation for ' + appid)
         return
@@ -1996,13 +1503,13 @@ def main():
         archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
 
     # Make the index for the main repo...
-    make_index(apps, sortedids, apks, repodirs[0], False)
+    index.make(apps, sortedids, apks, repodirs[0], False)
     make_categories_txt(repodirs[0], categories)
 
     # If there's an archive repo,  make the index for it. We already scanned it
     # earlier on.
     if len(repodirs) > 1:
-        make_index(apps, sortedids, archapks, repodirs[1], True)
+        index.make(apps, sortedids, archapks, repodirs[1], True)
 
     if config.get('binary_transparency_remote'):
         make_binary_transparency_log(repodirs)