chiark / gitweb /
sort index-v1; publish now creates and stores a list of signature fingerprints
[fdroidserver.git] / fdroidserver / index.py
1 #!/usr/bin/env python3
2 #
3 # update.py - part of the FDroid server tools
4 # Copyright (C) 2017, Torsten Grote <t at grobox dot de>
5 # Copyright (C) 2016, Blue Jay Wireless
6 # Copyright (C) 2014-2016, Hans-Christoph Steiner <hans@eds.org>
7 # Copyright (C) 2010-2015, Ciaran Gultnieks <ciaran@ciarang.com>
8 # Copyright (C) 2013-2014, Daniel Martí <mvdan@mvdan.cc>
9 #
10 # This program is free software: you can redistribute it and/or modify
11 # it under the terms of the GNU Affero General Public License as published by
12 # the Free Software Foundation, either version 3 of the License, or
13 # (at your option) any later version.
14 #
15 # This program is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 # GNU Affero General Public License for more details.
19 #
20 # You should have received a copy of the GNU Affero General Public License
21 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
22
23 import collections
24 import copy
25 import json
26 import logging
27 import os
28 import re
29 import shutil
30 import tempfile
31 import urllib.parse
32 import zipfile
33 from binascii import hexlify, unhexlify
34 from datetime import datetime
35 from xml.dom.minidom import Document
36
37 from . import _
38 from . import common
39 from . import metadata
40 from . import net
41 from . import signindex
42 from fdroidserver.common import FDroidPopen, FDroidPopenBytes, load_stats_fdroid_signing_key_fingerprints
43 from fdroidserver.exception import FDroidException, VerificationException, MetaDataException
44
45
46 def make(apps, sortedids, apks, repodir, archive):
47     """Generate the repo index files.
48
49     This requires properly initialized options and config objects.
50
51     :param apps: fully populated apps list
52     :param sortedids: app package IDs, sorted
53     :param apks: full populated apks list
54     :param repodir: the repo directory
55     :param archive: True if this is the archive repo, False if it's the
56                     main one.
57     """
58     from fdroidserver.update import METADATA_VERSION
59
60     def _resolve_description_link(appid):
61         if appid in apps:
62             return "fdroid.app:" + appid, apps[appid].Name
63         raise MetaDataException("Cannot resolve app id " + appid)
64
65     nosigningkey = False
66     if not common.options.nosign:
67         if 'repo_keyalias' not in common.config:
68             nosigningkey = True
69             logging.critical(_("'repo_keyalias' not found in config.py!"))
70         if 'keystore' not in common.config:
71             nosigningkey = True
72             logging.critical(_("'keystore' not found in config.py!"))
73         if 'keystorepass' not in common.config:
74             nosigningkey = True
75             logging.critical(_("'keystorepass' not found in config.py!"))
76         if 'keypass' not in common.config:
77             nosigningkey = True
78             logging.critical(_("'keypass' not found in config.py!"))
79         if not os.path.exists(common.config['keystore']):
80             nosigningkey = True
81             logging.critical("'" + common.config['keystore'] + "' does not exist!")
82         if nosigningkey:
83             raise FDroidException("`fdroid update` requires a signing key, " +
84                                   "you can create one using: fdroid update --create-key")
85
86     repodict = collections.OrderedDict()
87     repodict['timestamp'] = datetime.utcnow()
88     repodict['version'] = METADATA_VERSION
89
90     if common.config['repo_maxage'] != 0:
91         repodict['maxage'] = common.config['repo_maxage']
92
93     if archive:
94         repodict['name'] = common.config['archive_name']
95         repodict['icon'] = os.path.basename(common.config['archive_icon'])
96         repodict['address'] = common.config['archive_url']
97         repodict['description'] = common.config['archive_description']
98         urlbasepath = os.path.basename(urllib.parse.urlparse(common.config['archive_url']).path)
99     else:
100         repodict['name'] = common.config['repo_name']
101         repodict['icon'] = os.path.basename(common.config['repo_icon'])
102         repodict['address'] = common.config['repo_url']
103         repodict['description'] = common.config['repo_description']
104         urlbasepath = os.path.basename(urllib.parse.urlparse(common.config['repo_url']).path)
105
106     mirrorcheckfailed = False
107     mirrors = []
108     for mirror in sorted(common.config.get('mirrors', [])):
109         base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
110         if common.config.get('nonstandardwebroot') is not True and base != 'fdroid':
111             logging.error(_("mirror '%s' does not end with 'fdroid'!") % mirror)
112             mirrorcheckfailed = True
113         # must end with / or urljoin strips a whole path segment
114         if mirror.endswith('/'):
115             mirrors.append(urllib.parse.urljoin(mirror, urlbasepath))
116         else:
117             mirrors.append(urllib.parse.urljoin(mirror + '/', urlbasepath))
118     for mirror in common.config.get('servergitmirrors', []):
119         for url in get_mirror_service_urls(mirror):
120             mirrors.append(url + '/' + repodir)
121     if mirrorcheckfailed:
122         raise FDroidException(_("Malformed repository mirrors."))
123     if mirrors:
124         repodict['mirrors'] = mirrors
125
126     appsWithPackages = collections.OrderedDict()
127     for packageName in sortedids:
128         app = apps[packageName]
129         if app['Disabled']:
130             continue
131
132         # only include apps with packages
133         for apk in apks:
134             if apk['packageName'] == packageName:
135                 newapp = copy.copy(app)  # update wiki needs unmodified description
136                 newapp['Description'] = metadata.description_html(app['Description'],
137                                                                   _resolve_description_link)
138                 appsWithPackages[packageName] = newapp
139                 break
140
141     requestsdict = collections.OrderedDict()
142     for command in ('install', 'uninstall'):
143         packageNames = []
144         key = command + '_list'
145         if key in common.config:
146             if isinstance(common.config[key], str):
147                 packageNames = [common.config[key]]
148             elif all(isinstance(item, str) for item in common.config[key]):
149                 packageNames = common.config[key]
150             else:
151                 raise TypeError(_('only accepts strings, lists, and tuples'))
152         requestsdict[command] = packageNames
153
154     fdroid_signing_key_fingerprints = load_stats_fdroid_signing_key_fingerprints()
155
156     make_v0(appsWithPackages, apks, repodir, repodict, requestsdict,
157             fdroid_signing_key_fingerprints)
158     make_v1(appsWithPackages, apks, repodir, repodict, requestsdict,
159             fdroid_signing_key_fingerprints)
160
161
162 def make_v1(apps, packages, repodir, repodict, requestsdict, fdroid_signing_key_fingerprints):
163
164     def _index_encoder_default(obj):
165         if isinstance(obj, set):
166             return list(obj)
167         if isinstance(obj, datetime):
168             return int(obj.timestamp() * 1000)  # Java expects milliseconds
169         raise TypeError(repr(obj) + " is not JSON serializable")
170
171     output = collections.OrderedDict()
172     output['repo'] = repodict
173     output['requests'] = requestsdict
174
175     # establish sort order of the index
176     v1_sort_packages(packages, repodir, fdroid_signing_key_fingerprints)
177
178     appslist = []
179     output['apps'] = appslist
180     for packageName, appdict in apps.items():
181         d = collections.OrderedDict()
182         appslist.append(d)
183         for k, v in sorted(appdict.items()):
184             if not v:
185                 continue
186             if k in ('builds', 'comments', 'metadatapath',
187                      'ArchivePolicy', 'AutoUpdateMode', 'MaintainerNotes',
188                      'Provides', 'Repo', 'RepoType', 'RequiresRoot',
189                      'UpdateCheckData', 'UpdateCheckIgnore', 'UpdateCheckMode',
190                      'UpdateCheckName', 'NoSourceSince', 'VercodeOperation'):
191                 continue
192
193             # name things after the App class fields in fdroidclient
194             if k == 'id':
195                 k = 'packageName'
196             elif k == 'CurrentVersionCode':  # TODO make SuggestedVersionCode the canonical name
197                 k = 'suggestedVersionCode'
198             elif k == 'CurrentVersion':  # TODO make SuggestedVersionName the canonical name
199                 k = 'suggestedVersionName'
200             elif k == 'AutoName':
201                 if 'Name' not in apps[packageName]:
202                     d['name'] = v
203                 continue
204             else:
205                 k = k[:1].lower() + k[1:]
206             d[k] = v
207
208     output_packages = collections.OrderedDict()
209     output['packages'] = output_packages
210     for package in packages:
211         packageName = package['packageName']
212         if packageName not in apps:
213             logging.info(_('Ignoring package without metadata: ') + package['apkName'])
214             continue
215         if packageName in output_packages:
216             packagelist = output_packages[packageName]
217         else:
218             packagelist = []
219             output_packages[packageName] = packagelist
220         d = collections.OrderedDict()
221         packagelist.append(d)
222         for k, v in sorted(package.items()):
223             if not v:
224                 continue
225             if k in ('icon', 'icons', 'icons_src', 'name', ):
226                 continue
227             d[k] = v
228
229     json_name = 'index-v1.json'
230     index_file = os.path.join(repodir, json_name)
231     with open(index_file, 'w') as fp:
232         if common.options.pretty:
233             json.dump(output, fp, default=_index_encoder_default, indent=2)
234         else:
235             json.dump(output, fp, default=_index_encoder_default)
236
237     if common.options.nosign:
238         logging.debug(_('index-v1 must have a signature, use `fdroid signindex` to create it!'))
239     else:
240         signindex.config = common.config
241         signindex.sign_index_v1(repodir, json_name)
242
243
244 def v1_sort_packages(packages, repodir, fdroid_signing_key_fingerprints):
245
246     GROUP_DEV_SIGNED = 1
247     GROUP_FDROID_SIGNED = 2
248     GROUP_OTHER_SIGNED = 3
249
250     def v1_sort_keys(package):
251         packageName = package.get('packageName', None)
252
253         sig = package.get('signer', None)
254
255         dev_sig = common.metadata_find_developer_signature(packageName)
256         group = GROUP_OTHER_SIGNED
257         if dev_sig and dev_sig == sig:
258             group = GROUP_DEV_SIGNED
259         else:
260             fdroidsig = fdroid_signing_key_fingerprints.get(packageName, {}).get('signer')
261             if fdroidsig and fdroidsig == sig:
262                 group = GROUP_FDROID_SIGNED
263
264         versionCode = None
265         if package.get('versionCode', None):
266             versionCode = -int(package['versionCode'])
267
268         return(packageName, group, sig, versionCode)
269
270     packages.sort(key=v1_sort_keys)
271
272
273 def make_v0(apps, apks, repodir, repodict, requestsdict):
274     """
275     aka index.jar aka index.xml
276     """
277
278     doc = Document()
279
280     def addElement(name, value, doc, parent):
281         el = doc.createElement(name)
282         el.appendChild(doc.createTextNode(value))
283         parent.appendChild(el)
284
285     def addElementNonEmpty(name, value, doc, parent):
286         if not value:
287             return
288         addElement(name, value, doc, parent)
289
290     def addElementIfInApk(name, apk, key, doc, parent):
291         if key not in apk:
292             return
293         value = str(apk[key])
294         addElement(name, value, doc, parent)
295
296     def addElementCDATA(name, value, doc, parent):
297         el = doc.createElement(name)
298         el.appendChild(doc.createCDATASection(value))
299         parent.appendChild(el)
300
301     def addElementCheckLocalized(name, app, key, doc, parent, default=''):
302         '''Fill in field from metadata or localized block
303
304         For name/summary/description, they can come only from the app source,
305         or from a dir in fdroiddata.  They can be entirely missing from the
306         metadata file if there is localized versions.  This will fetch those
307         from the localized version if its not available in the metadata file.
308         '''
309
310         el = doc.createElement(name)
311         value = app.get(key)
312         lkey = key[:1].lower() + key[1:]
313         localized = app.get('localized')
314         if not value and localized:
315             for lang in ['en-US'] + [x for x in localized.keys()]:
316                 if not lang.startswith('en'):
317                     continue
318                 if lang in localized:
319                     value = localized[lang].get(lkey)
320                     if value:
321                         break
322         if not value and localized and len(localized) > 1:
323             lang = list(localized.keys())[0]
324             value = localized[lang].get(lkey)
325         if not value:
326             value = default
327         el.appendChild(doc.createTextNode(value))
328         parent.appendChild(el)
329
330     root = doc.createElement("fdroid")
331     doc.appendChild(root)
332
333     repoel = doc.createElement("repo")
334
335     repoel.setAttribute("name", repodict['name'])
336     if 'maxage' in repodict:
337         repoel.setAttribute("maxage", str(repodict['maxage']))
338     repoel.setAttribute("icon", os.path.basename(repodict['icon']))
339     repoel.setAttribute("url", repodict['address'])
340     addElement('description', repodict['description'], doc, repoel)
341     for mirror in repodict.get('mirrors', []):
342         addElement('mirror', mirror, doc, repoel)
343
344     repoel.setAttribute("version", str(repodict['version']))
345     repoel.setAttribute("timestamp", '%d' % repodict['timestamp'].timestamp())
346
347     pubkey, repo_pubkey_fingerprint = extract_pubkey()
348     repoel.setAttribute("pubkey", pubkey.decode('utf-8'))
349     root.appendChild(repoel)
350
351     for command in ('install', 'uninstall'):
352         for packageName in requestsdict[command]:
353             element = doc.createElement(command)
354             root.appendChild(element)
355             element.setAttribute('packageName', packageName)
356
357     for appid, appdict in apps.items():
358         app = metadata.App(appdict)
359
360         if app.Disabled is not None:
361             continue
362
363         # Get a list of the apks for this app...
364         apklist = []
365         versionCodes = []
366         for apk in apks:
367             if apk['packageName'] == appid:
368                 if apk['versionCode'] not in versionCodes:
369                     apklist.append(apk)
370                     versionCodes.append(apk['versionCode'])
371
372         if len(apklist) == 0:
373             continue
374
375         apel = doc.createElement("application")
376         apel.setAttribute("id", app.id)
377         root.appendChild(apel)
378
379         addElement('id', app.id, doc, apel)
380         if app.added:
381             addElement('added', app.added.strftime('%Y-%m-%d'), doc, apel)
382         if app.lastUpdated:
383             addElement('lastupdated', app.lastUpdated.strftime('%Y-%m-%d'), doc, apel)
384
385         addElementCheckLocalized('name', app, 'Name', doc, apel)
386         addElementCheckLocalized('summary', app, 'Summary', doc, apel)
387
388         if app.icon:
389             addElement('icon', app.icon, doc, apel)
390
391         addElementCheckLocalized('desc', app, 'Description', doc, apel,
392                                  '<p>No description available</p>')
393
394         addElement('license', app.License, doc, apel)
395         if app.Categories:
396             addElement('categories', ','.join(app.Categories), doc, apel)
397             # We put the first (primary) category in LAST, which will have
398             # the desired effect of making clients that only understand one
399             # category see that one.
400             addElement('category', app.Categories[0], doc, apel)
401         addElement('web', app.WebSite, doc, apel)
402         addElement('source', app.SourceCode, doc, apel)
403         addElement('tracker', app.IssueTracker, doc, apel)
404         addElementNonEmpty('changelog', app.Changelog, doc, apel)
405         addElementNonEmpty('author', app.AuthorName, doc, apel)
406         addElementNonEmpty('email', app.AuthorEmail, doc, apel)
407         addElementNonEmpty('donate', app.Donate, doc, apel)
408         addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
409         addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
410         addElementNonEmpty('flattr', app.FlattrID, doc, apel)
411
412         # These elements actually refer to the current version (i.e. which
413         # one is recommended. They are historically mis-named, and need
414         # changing, but stay like this for now to support existing clients.
415         addElement('marketversion', app.CurrentVersion, doc, apel)
416         addElement('marketvercode', app.CurrentVersionCode, doc, apel)
417
418         if app.Provides:
419             pv = app.Provides.split(',')
420             addElementNonEmpty('provides', ','.join(pv), doc, apel)
421         if app.RequiresRoot:
422             addElement('requirements', 'root', doc, apel)
423
424         # Sort the apk list into version order, just so the web site
425         # doesn't have to do any work by default...
426         apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
427
428         if 'antiFeatures' in apklist[0]:
429             app.AntiFeatures.extend(apklist[0]['antiFeatures'])
430         if app.AntiFeatures:
431             addElementNonEmpty('antifeatures', ','.join(app.AntiFeatures), doc, apel)
432
433         # Check for duplicates - they will make the client unhappy...
434         for i in range(len(apklist) - 1):
435             first = apklist[i]
436             second = apklist[i + 1]
437             if first['versionCode'] == second['versionCode'] \
438                and first['sig'] == second['sig']:
439                 if first['hash'] == second['hash']:
440                     raise FDroidException('"{0}/{1}" and "{0}/{2}" are exact duplicates!'.format(
441                         repodir, first['apkName'], second['apkName']))
442                 else:
443                     raise FDroidException('duplicates: "{0}/{1}" - "{0}/{2}"'.format(
444                         repodir, first['apkName'], second['apkName']))
445
446         current_version_code = 0
447         current_version_file = None
448         for apk in apklist:
449             file_extension = common.get_file_extension(apk['apkName'])
450             # find the APK for the "Current Version"
451             if current_version_code < apk['versionCode']:
452                 current_version_code = apk['versionCode']
453             if current_version_code < int(app.CurrentVersionCode):
454                 current_version_file = apk['apkName']
455
456             apkel = doc.createElement("package")
457             apel.appendChild(apkel)
458             addElement('version', apk['versionName'], doc, apkel)
459             addElement('versioncode', str(apk['versionCode']), doc, apkel)
460             addElement('apkname', apk['apkName'], doc, apkel)
461             addElementIfInApk('srcname', apk, 'srcname', doc, apkel)
462
463             hashel = doc.createElement("hash")
464             hashel.setAttribute('type', 'sha256')
465             hashel.appendChild(doc.createTextNode(apk['hash']))
466             apkel.appendChild(hashel)
467
468             addElement('size', str(apk['size']), doc, apkel)
469             addElementIfInApk('sdkver', apk,
470                               'minSdkVersion', doc, apkel)
471             addElementIfInApk('targetSdkVersion', apk,
472                               'targetSdkVersion', doc, apkel)
473             addElementIfInApk('maxsdkver', apk,
474                               'maxSdkVersion', doc, apkel)
475             addElementIfInApk('obbMainFile', apk,
476                               'obbMainFile', doc, apkel)
477             addElementIfInApk('obbMainFileSha256', apk,
478                               'obbMainFileSha256', doc, apkel)
479             addElementIfInApk('obbPatchFile', apk,
480                               'obbPatchFile', doc, apkel)
481             addElementIfInApk('obbPatchFileSha256', apk,
482                               'obbPatchFileSha256', doc, apkel)
483             if 'added' in apk:
484                 addElement('added', apk['added'].strftime('%Y-%m-%d'), doc, apkel)
485
486             if file_extension == 'apk':  # sig is required for APKs, but only APKs
487                 addElement('sig', apk['sig'], doc, apkel)
488
489                 old_permissions = set()
490                 sorted_permissions = sorted(apk['uses-permission'])
491                 for perm in sorted_permissions:
492                     perm_name = perm.name
493                     if perm_name.startswith("android.permission."):
494                         perm_name = perm_name[19:]
495                     old_permissions.add(perm_name)
496                 addElementNonEmpty('permissions', ','.join(sorted(old_permissions)), doc, apkel)
497
498                 for permission in sorted_permissions:
499                     permel = doc.createElement('uses-permission')
500                     permel.setAttribute('name', permission.name)
501                     if permission.maxSdkVersion is not None:
502                         permel.setAttribute('maxSdkVersion', '%d' % permission.maxSdkVersion)
503                         apkel.appendChild(permel)
504                 for permission_sdk_23 in sorted(apk['uses-permission-sdk-23']):
505                     permel = doc.createElement('uses-permission-sdk-23')
506                     permel.setAttribute('name', permission_sdk_23.name)
507                     if permission_sdk_23.maxSdkVersion is not None:
508                         permel.setAttribute('maxSdkVersion', '%d' % permission_sdk_23.maxSdkVersion)
509                         apkel.appendChild(permel)
510                 if 'nativecode' in apk:
511                     addElement('nativecode', ','.join(sorted(apk['nativecode'])), doc, apkel)
512                 addElementNonEmpty('features', ','.join(sorted(apk['features'])), doc, apkel)
513
514         if current_version_file is not None \
515                 and common.config['make_current_version_link'] \
516                 and repodir == 'repo':  # only create these
517             namefield = common.config['current_version_name_source']
518             sanitized_name = re.sub(b'''[ '"&%?+=/]''', b'', app.get(namefield).encode('utf-8'))
519             apklinkname = sanitized_name + os.path.splitext(current_version_file)[1].encode('utf-8')
520             current_version_path = os.path.join(repodir, current_version_file).encode('utf-8', 'surrogateescape')
521             if os.path.islink(apklinkname):
522                 os.remove(apklinkname)
523             os.symlink(current_version_path, apklinkname)
524             # also symlink gpg signature, if it exists
525             for extension in (b'.asc', b'.sig'):
526                 sigfile_path = current_version_path + extension
527                 if os.path.exists(sigfile_path):
528                     siglinkname = apklinkname + extension
529                     if os.path.islink(siglinkname):
530                         os.remove(siglinkname)
531                     os.symlink(sigfile_path, siglinkname)
532
533     if common.options.pretty:
534         output = doc.toprettyxml(encoding='utf-8')
535     else:
536         output = doc.toxml(encoding='utf-8')
537
538     with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
539         f.write(output)
540
541     if 'repo_keyalias' in common.config:
542
543         if common.options.nosign:
544             logging.info(_("Creating unsigned index in preparation for signing"))
545         else:
546             logging.info(_("Creating signed index with this key (SHA256):"))
547             logging.info("%s" % repo_pubkey_fingerprint)
548
549         # Create a jar of the index...
550         jar_output = 'index_unsigned.jar' if common.options.nosign else 'index.jar'
551         p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
552         if p.returncode != 0:
553             raise FDroidException("Failed to create {0}".format(jar_output))
554
555         # Sign the index...
556         signed = os.path.join(repodir, 'index.jar')
557         if common.options.nosign:
558             # Remove old signed index if not signing
559             if os.path.exists(signed):
560                 os.remove(signed)
561         else:
562             signindex.config = common.config
563             signindex.sign_jar(signed)
564
565     # Copy the repo icon into the repo directory...
566     icon_dir = os.path.join(repodir, 'icons')
567     iconfilename = os.path.join(icon_dir, os.path.basename(common.config['repo_icon']))
568     shutil.copyfile(common.config['repo_icon'], iconfilename)
569
570
571 def extract_pubkey():
572     """
573     Extracts and returns the repository's public key from the keystore.
574     :return: public key in hex, repository fingerprint
575     """
576     if 'repo_pubkey' in common.config:
577         pubkey = unhexlify(common.config['repo_pubkey'])
578     else:
579         env_vars = {'FDROID_KEY_STORE_PASS': common.config['keystorepass']}
580         p = FDroidPopenBytes([common.config['keytool'], '-exportcert',
581                               '-alias', common.config['repo_keyalias'],
582                               '-keystore', common.config['keystore'],
583                               '-storepass:env', 'FDROID_KEY_STORE_PASS']
584                              + common.config['smartcardoptions'],
585                              envs=env_vars, output=False, stderr_to_stdout=False)
586         if p.returncode != 0 or len(p.output) < 20:
587             msg = "Failed to get repo pubkey!"
588             if common.config['keystore'] == 'NONE':
589                 msg += ' Is your crypto smartcard plugged in?'
590             raise FDroidException(msg)
591         pubkey = p.output
592     repo_pubkey_fingerprint = common.get_cert_fingerprint(pubkey)
593     return hexlify(pubkey), repo_pubkey_fingerprint
594
595
596 def get_mirror_service_urls(url):
597     '''Get direct URLs from git service for use by fdroidclient
598
599     Via 'servergitmirrors', fdroidserver can create and push a mirror
600     to certain well known git services like gitlab or github.  This
601     will always use the 'master' branch since that is the default
602     branch in git. The files are then accessible via alternate URLs,
603     where they are served in their raw format via a CDN rather than
604     from git.
605     '''
606
607     if url.startswith('git@'):
608         url = re.sub(r'^git@(.*):(.*)', r'https://\1/\2', url)
609
610     segments = url.split("/")
611
612     if segments[4].endswith('.git'):
613         segments[4] = segments[4][:-4]
614
615     hostname = segments[2]
616     user = segments[3]
617     repo = segments[4]
618     branch = "master"
619     folder = "fdroid"
620
621     urls = []
622     if hostname == "github.com":
623         # Github-like RAW segments "https://raw.githubusercontent.com/user/repo/branch/folder"
624         segments[2] = "raw.githubusercontent.com"
625         segments.extend([branch, folder])
626         urls.append('/'.join(segments))
627     elif hostname == "gitlab.com":
628         # Gitlab Raw "https://gitlab.com/user/repo/raw/branch/folder"
629         gitlab_raw = segments + ['raw', branch, folder]
630         urls.append('/'.join(gitlab_raw))
631         # Gitlab-like Pages segments "https://user.gitlab.io/repo/folder"
632         gitlab_pages = ["https:", "", user + ".gitlab.io", repo, folder]
633         urls.append('/'.join(gitlab_pages))
634         return urls
635
636     return urls
637
638
639 def download_repo_index(url_str, etag=None, verify_fingerprint=True):
640     """
641     Downloads the repository index from the given :param url_str
642     and verifies the repository's fingerprint if :param verify_fingerprint is not False.
643
644     :raises: VerificationException() if the repository could not be verified
645
646     :return: A tuple consisting of:
647         - The index in JSON format or None if the index did not change
648         - The new eTag as returned by the HTTP request
649     """
650     url = urllib.parse.urlsplit(url_str)
651
652     fingerprint = None
653     if verify_fingerprint:
654         query = urllib.parse.parse_qs(url.query)
655         if 'fingerprint' not in query:
656             raise VerificationException(_("No fingerprint in URL."))
657         fingerprint = query['fingerprint'][0]
658
659     url = urllib.parse.SplitResult(url.scheme, url.netloc, url.path + '/index-v1.jar', '', '')
660     download, new_etag = net.http_get(url.geturl(), etag)
661
662     if download is None:
663         return None, new_etag
664
665     with tempfile.NamedTemporaryFile() as fp:
666         # write and open JAR file
667         fp.write(download)
668         jar = zipfile.ZipFile(fp)
669
670         # verify that the JAR signature is valid
671         common.verify_jar_signature(fp.name)
672
673         # get public key and its fingerprint from JAR
674         public_key, public_key_fingerprint = get_public_key_from_jar(jar)
675
676         # compare the fingerprint if verify_fingerprint is True
677         if verify_fingerprint and fingerprint.upper() != public_key_fingerprint:
678             raise VerificationException(_("The repository's fingerprint does not match."))
679
680         # load repository index from JSON
681         index = json.loads(jar.read('index-v1.json').decode("utf-8"))
682         index["repo"]["pubkey"] = hexlify(public_key).decode("utf-8")
683         index["repo"]["fingerprint"] = public_key_fingerprint
684
685         # turn the apps into App objects
686         index["apps"] = [metadata.App(app) for app in index["apps"]]
687
688         return index, new_etag
689
690
691 def get_public_key_from_jar(jar):
692     """
693     Get the public key and its fingerprint from a JAR file.
694
695     :raises: VerificationException() if the JAR was not signed exactly once
696
697     :param jar: a zipfile.ZipFile object
698     :return: the public key from the jar and its fingerprint
699     """
700     # extract certificate from jar
701     certs = [n for n in jar.namelist() if common.CERT_PATH_REGEX.match(n)]
702     if len(certs) < 1:
703         raise VerificationException(_("Found no signing certificates for repository."))
704     if len(certs) > 1:
705         raise VerificationException(_("Found multiple signing certificates for repository."))
706
707     # extract public key from certificate
708     public_key = common.get_certificate(jar.read(certs[0]))
709     public_key_fingerprint = common.get_cert_fingerprint(public_key).replace(' ', '')
710
711     return public_key, public_key_fingerprint