summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--README.md249
-rw-r--r--appstore/__init__.py0
-rw-r--r--appstore/settings.py102
-rw-r--r--appstore/urls.py21
-rw-r--r--appstore/wsgi.py14
-rwxr-xr-xmanage.py10
-rw-r--r--store/__init__.py0
-rw-r--r--store/admin.py152
-rw-r--r--store/api.py170
-rw-r--r--store/management/__init__.py0
-rw-r--r--store/management/commands/__init__.py0
-rw-r--r--store/management/commands/expire-downloads.py25
-rw-r--r--store/management/commands/store-sign-package.py31
-rw-r--r--store/management/commands/verify-upload-package.py22
-rw-r--r--store/migrations/0001_initial.py68
-rw-r--r--store/migrations/__init__.py0
-rw-r--r--store/models.py100
-rw-r--r--store/utilities.py339
19 files changed, 1308 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cdf247c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+*~
+*.pyc
+db.sqlite3
+media/
+certificates/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1bcdbfe
--- /dev/null
+++ b/README.md
@@ -0,0 +1,249 @@
+This is a PoC appstore server, which can be used together with
+the Neptune IVI UI and the Pelagicore Application Manager.
+
+**This is a development server only - do NOT use in production.**
+
+Architecture
+============
+
+The server is based on Python/Django.
+The reference platform is Debian Jessie and the packages needed there are:
+
+ * python (2.7.9)
+ * python-yaml (3.11)
+ * python-django (1.7.9)
+ * python-django-common (1.7.9)
+
+Before running the server, make sure to adapt the `APPSTORE_*` settings in
+`appstore/settings.py` to your environment.
+
+Since package downloads are done via temporary files, you need to setup
+a cron-job to cleanup these temporary files every now and then. The job
+should be triggerd every (`settings.APPSTORE_DOWNLOAD_EXPIRY` / 2) minutes
+and it just needs to execute:
+
+```
+ ./manage.py expire-downloads
+```
+
+Commands
+========
+
+* Running the server:
+ ```
+ ./manage.py runserver 0.0.0.0:8080
+ ```
+ will start the server on port 8080, reachable for anyone. You can tweak
+ the listening address to whatever fits your needs.
+
+* Cleaning up the downloads directory:
+ ```
+ ./manage.py expire-downloads
+ ```
+ will remove all files from the downloads/ directory, that are older than
+ `settings.APPSTORE_DOWNLOAD_EXPIRY` minutes.
+ This should be called from a cron-job (see above).
+
+* Manually verifying a package for upload:
+ ```
+ ./manage.py verify-upload-package <pkg.appkg>
+ ```
+ will tell you if `<pkg.appkg>` is a valid package that can be uploaded to
+ the store.
+
+* Manually adding a store signature to a package:
+ ```
+ ./manage.py store-sign-package <in.appkg> <out.appkg> [device id]
+ ```
+ will first verify `<in.appkg>`. If this succeeds, it will copy `<in.appkg>`
+ to `<out.appkg>` and add a store signature. The optional `[device id]`
+ parameter will lock the generated package to the device with this id.
+
+HTTP API
+========
+
+The appstore server exposes a HTTP API to the world. Arguments to the
+functions need to be provided using the HTTP GET syntax. The returned data
+will be JSON, PNG or text, depending on the function
+
+Basic workflow:
+
+1. Send a `"hello"` request to the server to get the current status and check
+ whether your platform is compatible with this server instance:
+ ```
+ http://<server>/hello?platform=AM&version=1
+ ```
+ Returns:
+ ```
+ { "status": "ok" }
+ ```
+
+2. Login as user `'user'` with password `'pass'`:
+ ```
+ http://<server>/login?username=user&password=pass
+ ```
+ Returns:
+ ```
+ { "status": "ok" }
+ ```
+
+3. List all applications
+ ```
+ http://<server>/app/list
+ ```
+ Returns:
+ ```
+ [{ "category": "Entertainment",
+ "rating": 5.0,
+ "name": "Nice App",
+ "price": 0.42,
+ "vendor": "Pelagicore",
+ "briefDescription": "Nice App is a really nice app.",
+ "category_id": 4,
+ "id": "com.pelagicore.niceapp"},
+ ...
+ ]
+ ```
+
+4. Request a download for a App:
+ ```
+ http://<server>/app/purchase?device_id=12345&id=com.pelagicore.niceapp
+ ```
+ Returns:
+ ```
+ { "status": "ok",
+ "url": "http://<server>/app/download/com.pelagicore.niceapp.2.npkg",
+ "expiresIn": 600
+ }
+ ```
+
+5. Use the `'url'` provided in step 4 to download the application within
+ `'expiresIn'` seconds.
+
+
+API Reference
+=============
+
+## hello
+Checks whether you are using the right Platform and the right API to communicate with the Server.
+
+| Parameter | Description |
+| ---------- | ----------- |
+| `platform` | The platform the client is running on, this sets the architecture of the packages you get. (see `settings.APPSTORE_PLATFORM`) |
+| `version` | The Appstore Server HTTP API version you are using to communicate with the server. (see `settings.APPSTORE_VERSION`) |
+
+Returns a JSON object:
+
+| JSON field | Value | Description |
+| ---------- | --------- | ----------- |
+| `status` | `ok` | Successfull. |
+| | `maintenance` | The Server is in maintenance mode and can't be used at the moment. |
+| | `incompatible-platform` | You are using an incompatible Platform. |
+| | `incompatible-version` | You are using an incompatible Version of the API. |
+
+## login
+Does a login on the Server with the given username and password. Either a imei or a mac must be provided. This call is needed for downloading apps.
+
+| Parameter | Description |
+| ---------- | ----------- |
+| `username` | The username. |
+| `password` | The password for the given username |
+
+Returns a JSON object:
+
+| JSON field | Value | Description |
+| ---------- | --------- | ----------- |
+| `status` | `ok` | Successfull. |
+| | `missing-credentials` | Forgot to provided username and/or password. |
+| | `account-disabled` | The account is disabled. |
+| | `authentication-failed` | Failed to authenticate with given username and password. |
+
+
+## logout
+Does a logout on the Server for the currently logged in user.
+
+Returns a JSON object:
+
+| JSON field | Value | Description |
+| ---------- | --------- | ----------- |
+| `status` | `ok` | Successfull. |
+| | `failed` | Not logged in. |
+
+## app/list
+Lists all apps. The returned List can be filtered by using the category_id and the filter argument.
+
+| Parameter | Description |
+| ------------- | ----------- |
+| `category_id` | Only lists apps, which are in the category with this id. |
+| `filter` | Only lists apps, whose name matches the filter. |
+
+Returns a JSON array (not an object!). Each field is a JSON object:
+
+| JSON field | Description |
+| ------------------ | ----------- |
+| `id` | The unique id of the application |
+| `name` | The name of the application |
+| `vendor` | The name of the vendor of this application
+| `rating` | The rating of this application
+| `price` | The price as floating point number
+| `briedDescription` | A short (one line) description of the application
+| `category` | The name of the category the application is in
+| `category_id` | The id of the category the application is in
+
+## app/icon
+ Returns an icon for the given application id.
+
+| Parameter | Description |
+| ---------- | ----------- |
+| `id` | The application id |
+
+ Returns a PNG image or a 404 error
+
+
+## app/description
+Returns a description for the given application id.
+
+| Parameter | Description |
+| ---------- | ----------- |
+| `id` | The application id |
+
+Returns text - either HTML or plain
+
+
+## app/purchase
+Returns an url which can be used for downloading the requested application for
+certain period of time (configurable in the settings)
+
+| Parameter | Description |
+| ----------- | ----------- |
+| `device_id` | The unique device id of the client hardware. |
+| `id` | The application Id. |
+
+Returns a JSON object:
+
+| JSON field | Value | Description |
+| ----------- | --------- | ----------- |
+| `status` | `ok` | Successfull. |
+| | `failed` | Something went wrong. See the `error` field for more information. |
+| `error` | **text** | An error description, if `status` is `failed. |
+| `url` | **url** | The url which can now be used for downloading the application. |
+| `expiresIn` | **int** | The time in seconds the url remains valid. |
+
+## category/list
+Lists all the available categories. It uses the rank stored on the server for ordering.
+
+Returns a JSON array (not an object!). Each field is a JSON object:
+
+| JSON field | Description |
+| ---------- | ----------- |
+| `id` | The unique id of the category |
+| `name` | The name of the category |
+
+## category/icon:
+Returns an icon for the given category id.
+
+| Parameter | Description |
+| ---------- | ----------- |
+| `id` | The id of the category |
+
+Returns a PNG image or a 404 error
diff --git a/appstore/__init__.py b/appstore/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/appstore/__init__.py
diff --git a/appstore/settings.py b/appstore/settings.py
new file mode 100644
index 0000000..6f6f559
--- /dev/null
+++ b/appstore/settings.py
@@ -0,0 +1,102 @@
+"""
+Django settings for appstore project.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.7/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/1.7/ref/settings/
+"""
+
+APPSTORE_MAINTENANCE = False
+APPSTORE_PLATFORM_ID = 'AM'
+APPSTORE_PLATFORM_VERSION = 1
+APPSTORE_DOWNLOAD_EXPIRY = 10 # in minutes
+APPSTORE_BIND_TO_DEVICE_ID = True # unique downloads for each device
+APPSTORE_STORE_SIGN_PKCS12_CERTIFICATE = 'certificates/store.p12'
+APPSTORE_STORE_SIGN_PKCS12_PASSWORD = 'password'
+APPSTORE_DEV_VERIFY_CA_CERTIFICATES = [ 'certificates/ca.crt', 'certificates/devca.crt' ]
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+import os
+BASE_DIR = os.path.dirname(os.path.dirname(__file__))
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = '4%(o_1zuz@^kjcarw&!5ptvk&#9oa1-83*arn6jcm4idzy1#30'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+TEMPLATE_DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = (
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'store',
+)
+
+MIDDLEWARE_CLASSES = (
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+)
+
+ROOT_URLCONF = 'appstore.urls'
+
+WSGI_APPLICATION = 'appstore.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/1.7/ref/settings/#databases
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+ }
+}
+
+# Internationalization
+# https://docs.djangoproject.com/en/1.7/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'Europe/Berlin'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/1.7/howto/static-files/
+
+STATIC_URL = '/static/'
+
+# Absolute path to the directory that holds media.
+# Example: "/home/media/media.lawrence.com/"
+MEDIA_ROOT = '/home/sandman/git/appstore-server/media/'
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash if there is a path component (optional in other cases).
+# Examples: "http://media.lawrence.com", "http://example.com/media/"
+MEDIA_URL = ''
diff --git a/appstore/urls.py b/appstore/urls.py
new file mode 100644
index 0000000..7b9a64c
--- /dev/null
+++ b/appstore/urls.py
@@ -0,0 +1,21 @@
+from django.conf.urls import patterns, include, url
+from django.contrib import admin
+
+urlpatterns = patterns('',
+ # Examples:
+ # url(r'^$', 'appstore.views.home', name='home'),
+ # url(r'^blog/', include('blog.urls')),
+
+ url(r'^admin/', include(admin.site.urls)),
+
+ url(r'^hello$', 'store.api.hello'),
+ url(r'^login$', 'store.api.login'),
+ url(r'^logout$', 'store.api.logout'),
+ url(r'^app/list$', 'store.api.appList'),
+ url(r'^app/icon', 'store.api.appIcon'),
+ url(r'^app/description', 'store.api.appDescription'),
+ url(r'^app/purchase', 'store.api.appPurchase'),
+ url(r'^app/download/(.*)$', 'store.api.appDownload'),
+ url(r'^category/list$', 'store.api.categoryList'),
+ url(r'^category/icon$', 'store.api.categoryIcon'),
+)
diff --git a/appstore/wsgi.py b/appstore/wsgi.py
new file mode 100644
index 0000000..87e04c4
--- /dev/null
+++ b/appstore/wsgi.py
@@ -0,0 +1,14 @@
+"""
+WSGI config for appstore project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/
+"""
+
+import os
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "appstore.settings")
+
+from django.core.wsgi import get_wsgi_application
+application = get_wsgi_application()
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000..0a90195
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,10 @@
+#!/usr/bin/env python
+import os
+import sys
+
+if __name__ == "__main__":
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "appstore.settings")
+
+ from django.core.management import execute_from_command_line
+
+ execute_from_command_line(sys.argv)
diff --git a/store/__init__.py b/store/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/store/__init__.py
diff --git a/store/admin.py b/store/admin.py
new file mode 100644
index 0000000..b053cde
--- /dev/null
+++ b/store/admin.py
@@ -0,0 +1,152 @@
+import os
+
+from django import forms
+from django.conf import settings
+from django.conf.urls import patterns
+from django.contrib import admin
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import redirect, get_object_or_404
+from django.utils.translation import ugettext as _
+from django.utils.translation import ugettext_lazy
+
+from store.models import *
+from utilities import parseAndValidatePackageMetadata
+from utilities import iconPath
+
+
+class CategoryAdmin(admin.ModelAdmin):
+ list_display = ('name', 'move')
+ ordering = ('rank',)
+
+ def save_model(self, request, obj, form, change):
+ obj.save()
+
+ def name(self, obj):
+ # just to forbid sorting by name
+ return obj.name
+ name.short_description = ugettext_lazy('Item caption')
+
+ def move(sefl, obj):
+ """
+ Returns html with links to move_up and move_down views.
+ """
+ button = u'<a href="%s"><img src="%simg/admin/arrow-%s.gif" /> %s</a>'
+ prefix = settings.STATIC_URL
+
+ link = '%d/move_up/' % obj.pk
+ html = button % (link, prefix, 'up', _('up')) + " | "
+ link = '%d/move_down/' % obj.pk
+ html += button % (link, prefix, 'down', _('down'))
+ return html
+ move.allow_tags = True
+ move.short_description = ugettext_lazy('Move')
+
+ def get_urls(self):
+ admin_view = self.admin_site.admin_view
+ urls = patterns('',
+ (r'^(?P<item_pk>\d+)/move_up/$', admin_view(self.move_up)),
+ (r'^(?P<item_pk>\d+)/move_down/$', admin_view(self.move_down)),
+ )
+ return urls + super(CategoryAdmin, self).get_urls()
+
+ def move_up(self, request, item_pk):
+ """
+ Decrease rank (change ordering) of the menu item with
+ id=``item_pk``.
+ """
+ if self.has_change_permission(request):
+ item = get_object_or_404(Category, pk=item_pk)
+ item.decrease_rank()
+ else:
+ raise PermissionDenied
+ return redirect('admin:appstore_category_changelist')
+
+ def move_down(self, request, item_pk):
+ """
+ Increase rank (change ordering) of the menu item with
+ id=``item_pk``.
+ """
+ if self.has_change_permission(request):
+ item = get_object_or_404(Category, pk=item_pk)
+ item.increase_rank()
+ else:
+ raise PermissionDenied
+ return redirect('admin:appstore_category_changelist')
+
+
+class AppAdminForm(forms.ModelForm):
+ class Meta:
+ exclude = ["id", "name"]
+
+ appId = ""
+ name = ""
+
+ def clean(self):
+ cleaned_data = super(AppAdminForm, self).clean()
+ file = cleaned_data.get('file');
+
+ # validate package
+ pkgdata = None;
+ try:
+ chainOfTrust = []
+ for cert in settings.APPSTORE_DEV_VERIFY_CA_CERTIFICATES:
+ with open(cert, 'rb') as file:
+ chainOfTrust.append(file.read())
+
+ pkgdata = parseAndValidatePackageMetadata(file, chainOfTrust)
+ except Exception as error:
+ raise forms.ValidationError(_('Validation error: %s' % str(error)))
+
+ self.appId = pkgdata['info']['id'];
+ self.name = pkgdata['storeName'];
+
+ try:
+ a = App.objects.get(name__exact = self.name)
+ if a.id != pkgdata['info']['id']:
+ raise forms.ValidationError(_('Validation error: the same package name (%s) is already used for application %s' % (self.name, a.id)))
+ except App.DoesNotExist:
+ pass
+
+ # check if this really is an update
+ if hasattr(self, 'instance') and self.instance.id:
+ if self.appId != self.instance.id:
+ raise forms.ValidationError(_('Validation error: an update cannot change the application id, tried to change from %s to %s' % (self.instance.id, self.appId)))
+ else:
+ try:
+ if App.objects.get(id__exact = self.appId):
+ raise forms.ValidationError(_('Validation error: another application with id %s already exists' % str(self.appId)))
+ except App.DoesNotExist:
+ pass
+
+ # write icon into file to serve statically
+ try:
+ if not os.path.exists(iconPath()):
+ os.makedirs(iconPath())
+ tempicon = open(iconPath(self.appId), 'w')
+ tempicon.write(pkgdata['icon'])
+ tempicon.flush()
+ tempicon.close()
+
+ except IOError as error:
+ raise forms.ValidationError(_('Validation error: could not write icon file to media directory: %s' % str(error)))
+
+ return cleaned_data
+
+ def save(self, commit=False):
+ m = super(AppAdminForm, self).save(commit);
+ m.id = self.appId
+ m.name = self.name
+ return m
+
+
+class AppAdmin(admin.ModelAdmin):
+ form = AppAdminForm
+ list_display = ('name',)
+
+ def save_model(self, request, obj, form, change):
+ obj.save()
+
+
+admin.site.register(Category, CategoryAdmin)
+admin.site.register(Vendor)
+admin.site.register(App, AppAdmin)
diff --git a/store/api.py b/store/api.py
new file mode 100644
index 0000000..24ddf35
--- /dev/null
+++ b/store/api.py
@@ -0,0 +1,170 @@
+import os
+import tempfile
+import datetime
+import shutil
+import json
+
+from django.conf import settings
+from django.http import HttpResponse, HttpResponseForbidden, Http404, JsonResponse
+from django.contrib import auth
+from django.template import Context, loader
+
+from models import App, Category, Vendor
+from utilities import parsePackageMetadata, packagePath, iconPath, downloadPath, addSignatureToPackage
+
+
+def hello(request):
+ status = 'ok'
+
+ if settings.APPSTORE_MAINTENANCE:
+ status = 'maintenance'
+ elif request.REQUEST.get("platform", "") != str(settings.APPSTORE_PLATFORM_ID):
+ status = 'incompatible-platform'
+ elif request.REQUEST.get("version", "") != str(settings.APPSTORE_PLATFORM_VERSION):
+ status = 'incompatible-version'
+
+ return JsonResponse({'status': status})
+
+
+def login(request):
+ status = 'ok'
+
+ try:
+ try:
+ username = request.REQUEST["username"]
+ password = request.REQUEST["password"]
+ except KeyError:
+ raise Exception('missing-credentials')
+
+ user = auth.authenticate(username = username, password = password)
+ if user is None:
+ raise Exception('authentication-failed')
+
+ if not user.is_active:
+ raise Exception('account-disabled')
+
+ auth.login(request, user)
+
+ except Exception as error:
+ status = str(error)
+
+ return JsonResponse({'status': status})
+
+
+def logout(request):
+ status = 'ok'
+
+ if not request.user.is_authenticated():
+ status = 'failed'
+ logout(request)
+
+ return JsonResponse({'status': status})
+
+
+def appList(request):
+ apps = App.objects.all()
+ if 'filter' in request.REQUEST:
+ apps = apps.filter(name__contains = request.REQUEST['filter'])
+ if 'category_id' in request.REQUEST:
+ catId = request.REQUEST['category_id']
+ if catId == '0':
+ apps = apps.filter(isTopApp__exact = True)
+ else:
+ apps = apps.filter(category__exact = catId)
+
+ appList = list(apps.values('id', 'name', 'vendor__name', 'rating', 'price', 'briefDescription', 'category'))
+ for app in appList:
+ app['price'] = float(app['price'])
+ app['category_id'] = app['category']
+ app['category'] = Category.objects.all().filter(id__exact = app['category_id'])[0].name
+ app['vendor'] = app['vendor__name']
+ del app['vendor__name']
+
+ # this is not valid JSON, since we are returning a list!
+ return JsonResponse(appList, safe = False)
+
+
+def appDescription(request):
+ try:
+ app = App.objects.get(id__exact = request.REQUEST['id'])
+ return HttpResponse(app.description)
+ except:
+ raise Http404('no such application: %s' % request.REQUEST['id'])
+
+
+def appIcon(request):
+ try:
+ app = App.objects.get(id__exact = request.REQUEST['id'])
+ with open(iconPath(app.id), 'rb') as iconPng:
+ response = HttpResponse(content_type = 'image/png')
+ response.write(iconPng.read())
+ return response
+ except:
+ raise Http404('no such application: %s' % request.REQUEST['id'])
+
+
+def appPurchase(request):
+ if not request.user.is_authenticated():
+ return HttpResponseForbidden('no login')
+
+ try:
+ deviceId = str(request.REQUEST.get("device_id", ""))
+ if settings.APPSTORE_BIND_TO_DEVICE_ID:
+ if not deviceId:
+ return JsonResponse({'status': 'failed', 'error': 'device_id required'})
+ else:
+ deviceId = ''
+
+ app = App.objects.get(id__exact = request.REQUEST['id'])
+ fromFilePath = packagePath(app.id)
+
+ # we should not use obvious names here, but just hash the string.
+ # this would be a nightmare to debug though and this is a development server :)
+ toFile = str(app.id) + '_' + str(request.user.id) + '_' + str(deviceId) + '.appkg'
+ toPath = downloadPath()
+ if not os.path.exists(toPath):
+ os.makedirs(toPath)
+
+ with open(fromFilePath, 'rb') as package:
+ pkgdata = parsePackageMetadata(package)
+ addSignatureToPackage(fromFilePath, toPath + toFile, pkgdata['rawDigest'], deviceId)
+
+ return JsonResponse({'status': 'ok',
+ 'url': request.build_absolute_uri('/app/download/' + toFile),
+ 'expiresIn': int(settings.APPSTORE_DOWNLOAD_EXPIRY) * 60})
+
+ # a cronjob runing "manage.py expiredownloads" every settings.APPSTORE_DOWNLOAD_EXPIRY/2
+ # minutes will take care of removing these temporary download files.
+ except Exception as error:
+ return JsonResponse({ 'status': 'failed', 'error': str(error)})
+
+
+def appDownload(request, path):
+ try:
+ response = HttpResponse(content_type = 'application/octetstream')
+ with open(downloadPath() + path, 'rb') as pkg:
+ response.write(pkg.read())
+ response['Content-Length'] = pkg.tell()
+ return response
+ except:
+ raise Http404
+
+
+def categoryList(request):
+ # this is not valid JSON, since we are returning a list!
+ return JsonResponse(list(Category.objects.all().order_by('rank').values('id', 'name')), safe = False)
+
+
+def categoryIcon(request):
+ response = HttpResponse(content_type = 'image/png')
+
+ # there are no category icons (yet), so we just return the icon of the first app in this category
+ try:
+ app = App.objects.filter(category__exact = request.REQUEST['id']).order_by('-dateModified')[0]
+ with open(iconPath(app.id), 'rb') as iconPng:
+ response.write(iconPng.read())
+ except:
+ emptyPng = '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x01\x03\x00\x00\x00%\xdbV\xca\x00\x00\x00\x03PLTE\x00\x00\x00\xa7z=\xda\x00\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\nIDAT\x08\xd7c`\x00\x00\x00\x02\x00\x01\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82'
+ response.write(emptyPng)
+
+ return response
diff --git a/store/management/__init__.py b/store/management/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/store/management/__init__.py
diff --git a/store/management/commands/__init__.py b/store/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/store/management/commands/__init__.py
diff --git a/store/management/commands/expire-downloads.py b/store/management/commands/expire-downloads.py
new file mode 100644
index 0000000..3f13900
--- /dev/null
+++ b/store/management/commands/expire-downloads.py
@@ -0,0 +1,25 @@
+import os
+import time
+
+from django.core.management.base import BaseCommand, CommandError
+from django.conf import settings
+
+from store.utilities import downloadPath
+
+class Command(BaseCommand):
+ help = 'Expires all downloads that are older than 10 minutes'
+
+ def handle(self, *args, **options):
+ self.stdout.write('Removing expired download packages')
+ pkgPath = downloadPath()
+ if not os.path.exists(pkgPath):
+ os.makedirs(pkgPath)
+
+ for pkg in os.listdir(pkgPath):
+ t = os.path.getmtime(pkgPath + pkg)
+ age = time.time() - t
+ if age > (10 * 60):
+ os.remove(pkgPath + pkg)
+ self.stdout.write(' -> %s (age: %s seconds)' % (pkg, int(age)))
+
+ self.stdout.write('Done')
diff --git a/store/management/commands/store-sign-package.py b/store/management/commands/store-sign-package.py
new file mode 100644
index 0000000..b3bbe62
--- /dev/null
+++ b/store/management/commands/store-sign-package.py
@@ -0,0 +1,31 @@
+import sys
+
+from django.core.management.base import BaseCommand, CommandError
+from django.conf import settings
+
+from store.utilities import parseAndValidatePackageMetadata, addSignatureToPackage
+
+class Command(BaseCommand):
+ help = 'Adds a store signature to the package'
+
+ def handle(self, *args, **options):
+ if 2 > len(args) > 3:
+ raise CommandError('Usage: manage.py store-sign-package <source package> <destination-package> [device id]')
+
+ sourcePackage = args[0]
+ destinationPackage = args[1]
+ deviceId = args[2] if len(args) == 3 else ''
+
+ try:
+ self.stdout.write('Parsing package %s' % sourcePackage)
+ packageFile = open(sourcePackage, 'rb')
+ pkgdata = parseAndValidatePackageMetadata(packageFile)
+ self.stdout.write(' -> passed validation (internal name: %s)\n' % pkgdata['storeName'])
+
+ self.stdout.write('Adding signature to package %s' % destinationPackage)
+ addSignatureToPackage(sourcePackage, destinationPackage, pkgdata['rawDigest'], deviceId)
+ self.stdout.write(' -> finished')
+
+ except Exception as error:
+ self.stdout.write(' -> failed: %s\n' % str(error))
+ raise
diff --git a/store/management/commands/verify-upload-package.py b/store/management/commands/verify-upload-package.py
new file mode 100644
index 0000000..bb3cf4d
--- /dev/null
+++ b/store/management/commands/verify-upload-package.py
@@ -0,0 +1,22 @@
+import sys
+
+from django.core.management.base import BaseCommand, CommandError
+
+from store.utilities import parseAndValidatePackageMetadata
+
+class Command(BaseCommand):
+ help = 'Checks if packages are valid for store upload'
+
+ def handle(self, *args, **options):
+ if len(args) != 1:
+ raise CommandError('Usage: manage.py verify-upload-package <package>')
+
+ try:
+ self.stdout.write('Parsing package %s' % args[0])
+ packageFile = open(args[0], 'rb')
+ pkgdata = parseAndValidatePackageMetadata(packageFile)
+
+ self.stdout.write(' -> passed validation (internal name: %s)\n' % pkgdata['storeName'])
+
+ except Exception as error:
+ self.stdout.write(' -> failed: %s\n' % str(error))
diff --git a/store/migrations/0001_initial.py b/store/migrations/0001_initial.py
new file mode 100644
index 0000000..8144e3c
--- /dev/null
+++ b/store/migrations/0001_initial.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.conf import settings
+import store.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auth', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='App',
+ fields=[
+ ('id', models.CharField(max_length=200, serialize=False, primary_key=True)),
+ ('name', models.CharField(max_length=200)),
+ ('file', models.FileField(upload_to=store.models.content_file_name)),
+ ('briefDescription', models.TextField()),
+ ('description', models.TextField()),
+ ('dateAdded', models.DateField(auto_now_add=True)),
+ ('dateModified', models.DateField(auto_now=True)),
+ ('rating', models.FloatField()),
+ ('isTopApp', models.BooleanField(default=False)),
+ ('price', models.DecimalField(max_digits=8, decimal_places=2)),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
+ migrations.CreateModel(
+ name='Category',
+ fields=[
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('name', models.CharField(max_length=200)),
+ ('rank', models.SmallIntegerField(unique=True, db_index=True)),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
+ migrations.CreateModel(
+ name='Vendor',
+ fields=[
+ ('user', models.ForeignKey(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
+ ('name', models.CharField(max_length=200)),
+ ('certificate', models.TextField(max_length=8000)),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
+ migrations.AddField(
+ model_name='app',
+ name='category',
+ field=models.ForeignKey(to='store.Category'),
+ preserve_default=True,
+ ),
+ migrations.AddField(
+ model_name='app',
+ name='vendor',
+ field=models.ForeignKey(to='store.Vendor'),
+ preserve_default=True,
+ ),
+ ]
diff --git a/store/migrations/__init__.py b/store/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/store/migrations/__init__.py
diff --git a/store/models.py b/store/models.py
new file mode 100644
index 0000000..739225a
--- /dev/null
+++ b/store/models.py
@@ -0,0 +1,100 @@
+from django.db import models
+from django.conf import settings
+from django.contrib.auth.models import User
+
+from utilities import packagePath
+
+
+class Category(models.Model):
+ name = models.CharField(max_length = 200)
+ rank = models.SmallIntegerField(unique = True, db_index = True)
+
+ def __unicode__(self):
+ return self.name
+
+ def is_first(self):
+ """
+Returns ``True`` if item is the first one in the menu.
+"""
+ return Category.objects.filter(rank__lt = self.rank).count() == 0
+
+ def is_last(self):
+ """
+Returns ``True`` if item is the last one in the menu.
+"""
+ return Category.objects.filter(rank__gt = self.rank).count() == 0
+
+ def increase_rank(self):
+ """
+Changes position of this item with the next item in the
+menu. Does nothing if this item is the last one.
+"""
+ try:
+ next_item = Category.objects.filter(rank__gt = self.rank)[0]
+ except IndexError:
+ pass
+ else:
+ self.swap_ranks(next_item)
+
+ def decrease_rank(self):
+ """
+Changes position of this item with the previous item in the
+menu. Does nothing if this item is the first one.
+"""
+ try:
+ list = Category.objects.filter(rank__lt = self.rank).reverse()
+ prev_item = list[len(list) - 1]
+ except IndexError:
+ pass
+ else:
+ self.swap_ranks(prev_item)
+
+ def swap_ranks(self, other):
+ """
+Swap positions with ``other`` menu item.
+"""
+ maxrank = 5000
+ print(maxrank)
+ prev_rank, self.rank = self.rank, maxrank
+ self.save()
+ self.rank, other.rank = other.rank, prev_rank
+ other.save()
+ self.save()
+
+class Vendor(models.Model):
+ user = models.ForeignKey(User, primary_key = True)
+ name = models.CharField(max_length = 200)
+ certificate = models.TextField(max_length = 8000)
+
+ def __unicode__(self):
+ return self.name
+
+
+def content_file_name(instance, filename):
+ return packagePath(instance.id)
+
+class App(models.Model):
+ id = models.CharField(max_length = 200, primary_key=True)
+ name = models.CharField(max_length = 200)
+ file = models.FileField(upload_to = content_file_name)
+ vendor = models.ForeignKey(Vendor)
+ category = models.ForeignKey(Category)
+ briefDescription = models.TextField()
+ description = models.TextField()
+ dateAdded = models.DateField(auto_now_add = True)
+ dateModified = models.DateField(auto_now = True)
+ rating = models.FloatField()
+ isTopApp = models.BooleanField(default = False)
+ price = models.DecimalField(decimal_places = 2, max_digits = 8)
+
+ def __unicode__(self):
+ return self.name + " [" + self.id + "]"
+
+ def save(self, *args, **kwargs):
+ try:
+ this = App.objects.get(id=self.id)
+ if this.file != self.file:
+ this.file.delete(save=False)
+ except:
+ pass
+ super(App, self).save(*args, **kwargs)
diff --git a/store/utilities.py b/store/utilities.py
new file mode 100644
index 0000000..e2d9c4c
--- /dev/null
+++ b/store/utilities.py
@@ -0,0 +1,339 @@
+import tarfile
+import hashlib
+import hmac
+import yaml
+import sys
+import tarfile
+import tempfile
+import base64
+
+from M2Crypto import SMIME, BIO, X509
+from OpenSSL.crypto import load_pkcs12, FILETYPE_PEM, dump_privatekey, dump_certificate
+
+from django.conf import settings
+
+
+def packagePath(appId = None):
+ path = settings.MEDIA_ROOT + 'packages/'
+ if appId is not None:
+ return path + appId
+ return path
+
+def iconPath(appId = None):
+ path = settings.MEDIA_ROOT + 'icons/'
+ if appId is not None:
+ return path + appId + '.png'
+ return path
+
+def downloadPath():
+ return settings.MEDIA_ROOT + 'downloads/'
+
+
+def isValidDnsName(dnsName, errorString):
+ # see also in AM: src/common-lib/utilities.cpp / isValidDnsName()
+
+ try:
+ # this is not based on any RFC, but we want to make sure that this id is usable as filesystem
+ # name. So in order to support FAT (SD-Cards), we need to keep the path < 256 characters
+
+ if len(dnsName) > 200:
+ raise Exception('too long - the maximum length is 200 characters')
+
+ # we require at least 3 parts: tld.company-name.application-name
+ # this make it easier for humans to identify apps by id.
+
+ labels = dnsName.split('.')
+ if len(labels) < 3:
+ raise Exception('wrong format - needs to consist of at least three parts separated by .')
+
+ # standard domain name requirements from the RFCs 1035 and 1123
+
+ for label in labels:
+ if 0 >= len(label) > 63:
+ raise Exception('wrong format - each part of the name needs to at least 1 and at most 63 characters')
+
+ for i, c in enumerate(label):
+ isAlpha = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'z');
+ isDash = (c == '-');
+ isInMiddle = (i > 0) and (i < (len(label) - 1));
+
+ if not (isAlpha or (isDash and isInMiddle)):
+ raise Exception('invalid characters - only [a-z0-9-] are allowed (and '-' cannot be the first or last character)')
+
+ return True
+
+ except Exception as error:
+ errorString = str(error)
+ return False
+
+
+def verifySignature(signaturePkcs7, hash, chainOfTrust):
+ # see also in AM: src/crypto-lib/signature.cpp / Signature::verify()
+
+ s = SMIME.SMIME()
+
+ bioSignature = BIO.MemoryBuffer(data = base64.decodestring(signaturePkcs7))
+ signature = SMIME.load_pkcs7_bio(bioSignature)
+ bioHash = BIO.MemoryBuffer(data = hash)
+ certChain = X509.X509_Store()
+
+ for trustedCert in chainOfTrust:
+ bioCert = BIO.MemoryBuffer(data = trustedCert)
+
+ while len(bioCert):
+ cert = X509.load_cert_bio(bioCert, X509.FORMAT_PEM)
+ certChain.add_x509(cert)
+
+ s.set_x509_store(certChain)
+ s.set_x509_stack(X509.X509_Stack())
+
+ s.verify(signature, bioHash, SMIME.PKCS7_NOCHAIN)
+
+
+def createSignature(hash, signingCertificatePkcs12, signingCertificatePassword):
+ # see also in AM: src/crypto-lib/signature.cpp / Signature::create()
+
+ s = SMIME.SMIME()
+
+ # M2Crypto has no support for PKCS#12, so we have to use pyopenssl here
+ # to load the .p12. Since the internal structures are incompatible, we
+ # have to export from pyopenssl and import to M2Crypto via PEM BIOs.
+ pkcs12 = load_pkcs12(signingCertificatePkcs12, signingCertificatePassword)
+ signKey = BIO.MemoryBuffer(dump_privatekey(FILETYPE_PEM, pkcs12.get_privatekey()))
+ signCert = BIO.MemoryBuffer(dump_certificate(FILETYPE_PEM, pkcs12.get_certificate()))
+ caCerts = X509.X509_Stack()
+ if pkcs12.get_ca_certificates():
+ for cert in pkcs12.get_ca_certificates():
+ bio = BIO.MemoryBuffer(dump_certificate(FILETYPE_PEM, cert))
+ caCerts.push(X509.load_cert_bio(bio, X509.FORMAT_PEM))
+
+ bioHash = BIO.MemoryBuffer(hash)
+
+ s.load_key_bio(signKey, signCert)
+ s.set_x509_stack(caCerts)
+ signature = s.sign(bioHash, SMIME.PKCS7_DETACHED + SMIME.PKCS7_BINARY)
+ bioSignature = BIO.MemoryBuffer()
+ signature.write(bioSignature)
+
+ data = bioSignature.read_all()
+ return data
+
+
+def parsePackageMetadata(packageFile):
+ pkgdata = { }
+
+ pkg = tarfile.open(fileobj=packageFile, mode='r:*', encoding='utf-8');
+
+ fileCount = 0
+ foundFooter = False
+ footerContents = ''
+ foundInfo = False
+ foundIcon = False
+ digest = hashlib.new('sha256')
+
+ for entry in pkg:
+ fileCount = fileCount + 1
+
+ if not entry.isfile() and not entry.isdir():
+ raise Exception('only files and directories are allowed: %s' % entry.name)
+ elif entry.name.startswith('/'):
+ raise Exception('no absolute paths are allowed: %s' % entry.name)
+ elif entry.name.find('..') >= 0:
+ raise Exception('no non-canonical paths are allowed: %s' % entry.name)
+ elif 0 > entry.size > 2**31-1:
+ raise Exception('file size > 2GiB: %s' % entry.name)
+ elif entry.name.startswith('--PACKAGE-') and entry.isdir():
+ raise Exception('all reserved entries (starting with --PACKAGE-) need to be files, found %s' % entry.name)
+
+ contents = None
+ if entry.isfile():
+ try:
+ contents = pkg.extractfile(entry).read()
+ except Exception as error:
+ raise Exception('Could not extract file %s: %s' % (entry.name, str(error)))
+
+ if entry.name == '--PACKAGE-HEADER--':
+ if fileCount != 1:
+ raise Exception('file --PACKAGE-HEADER-- found at index %d, but it needs to be the first file in the package' % fileCount)
+
+ try:
+ docs = list(yaml.safe_load_all(contents))
+ except yaml.YAMLError as error:
+ raise Exception('Could not parse --PACKAGE-HEADER--: %s' % error)
+
+ if len(docs) != 2:
+ raise Exception('file --PACKAGE-HEADER-- does not consist of 2 YAML documents')
+ if docs[0]['formatVersion'] != 1 or docs[0]['formatType'] != 'am-package-header':
+ raise Exception('file --PACKAGE-HEADER-- has an invalid document type')
+
+ pkgdata['header'] = docs[1]
+ elif fileCount == 1:
+ raise Exception('the first file in the package is not --PACKAGE-HEADER--, but %s' % entry.name)
+
+ if entry.name.startswith('--PACKAGE-FOOTER--'):
+ footerContents += contents
+
+ foundFooter = True
+ elif foundFooter:
+ raise Exception('no normal files are allowed after the first --PACKAGE-FOOTER-- (found %s)' % entry.name)
+
+ if not entry.name.startswith('--PACKAGE-'):
+ addToDigest1 = '%s/%s/' % ('D' if entry.isdir() else 'F', 0 if entry.isdir() else entry.size)
+ addToDigest2 = unicode(entry.name, 'utf-8').encode('utf-8')
+
+ #print >>sys.stderr, addToDigest1
+ #print >>sys.stderr, addToDigest2
+
+ digest.update(contents)
+ digest.update(addToDigest1)
+ digest.update(addToDigest2)
+
+ if entry.name == 'info.yaml':
+ if fileCount != 2:
+ raise Exception('file info.yaml found at index %d, but it needs to be the second file of the package' % fileCount)
+
+ try:
+ docs = list(yaml.safe_load_all(contents))
+ except yaml.YAMLError as error:
+ raise Exception('Could not parse %s: %s' % (entry.name, error))
+
+ if len(docs) != 2:
+ raise Exception('file %s does not consist of 2 YAML documents' % entry.name)
+ if docs[0]['formatVersion'] != 1 or docs[0]['formatType'] != 'am-application':
+ raise Exception('file %s has an invalid document type' % entry.name)
+
+ pkgdata['info'] = docs[1]
+ foundInfo = True
+
+ elif entry.name == 'icon.png':
+ if fileCount != 3:
+ raise Exception('file icon.png found at index %d, but it needs to be the third file in the package' % fileCount)
+ pkgdata['icon'] = contents
+ foundIcon = True
+
+ elif not foundInfo and not foundInfo and fileCount >= 2:
+ raise Exception('package does not start with info.yaml and icon.png - found %s' % entry.name)
+
+ # finished enumerating all files
+ try:
+ docs = list(yaml.safe_load_all(footerContents))
+ except yaml.YAMLError as error:
+ raise Exception('Could not parse %s: %s' % (entry.name, error))
+
+ if len(docs) < 2:
+ raise Exception('file --PACKAGE-FOOTER-- does not consist of at least 2 YAML documents')
+ if docs[0]['formatVersion'] != 1 or docs[0]['formatType'] != 'am-package-footer':
+ raise Exception('file --PACKAGE-FOOTER-- has an invalid document type')
+
+ pkgdata['footer'] = docs[1]
+ for doc in docs[2:]:
+ pkgdata['footer'].update(doc)
+
+ pkgdata['digest'] = digest.hexdigest()
+ pkgdata['rawDigest'] = digest.digest()
+
+ pkg.close()
+ return pkgdata
+
+
+def parseAndValidatePackageMetadata(packageFile, certificates = []):
+ pkgdata = parsePackageMetadata(packageFile)
+
+ try:
+ partFields = { 'header': [ 'applicationId', 'diskSpaceUsed' ],
+ 'info': [ 'id', 'name', 'icon' ],
+ 'footer': [ 'digest', 'developerSignature' ],
+ 'icon': [],
+ 'digest': [] }
+
+ for part in partFields.keys():
+ if not part in pkgdata:
+ raise Exception('package metadata is missing the %s part' % part)
+ data = pkgdata[part]
+
+ for field in partFields[part]:
+ if field not in data:
+ raise Exception('metadata %s is missing in the %s part' % (field, part))
+
+ if pkgdata['header']['applicationId'] != pkgdata['info']['id']:
+ raise Exception('the id fields in --PACKAGE-HEADER-- and info.yaml are different: %s vs. %s' % (pkgdata['header']['applicationId'], pkgdata['info']['id']))
+
+ error = ''
+ if not isValidDnsName(pkgdata['info']['id'], error):
+ raise Exception('invalid id: %s' % error)
+
+ if pkgdata['header']['diskSpaceUsed'] <= 0:
+ raise Exception('the diskSpaceUsed field in --PACKAGE-HEADER-- is not > 0, but %d' % pkgdata['header']['diskSpaceUsed'])
+
+ if type(pkgdata['info']['name']) != type({}):
+ raise Exception('invalid name: not a dictionary')
+
+ name = ''
+ if 'en' in pkgdata['info']['name']:
+ name = pkgdata['info']['name']['en']
+ elif 'en_US' in pkgdata['info']['name']:
+ name = pkgdata['info']['name']['en_US']
+ elif len(pkgdata['info']['name']) > 0:
+ name = pkgdata['info']['name'].values()[0]
+
+ if len(name) == 0:
+ raise Exception('could not deduce a suitable package name from the info part')
+
+ pkgdata['storeName'] = name
+
+ if pkgdata['digest'] != pkgdata['footer']['digest']:
+ raise Exception('digest does not match, is: %s, but should be %s' % (pkgdata['digest'], pkgdata['footer']['digest']))
+ if 'storeSignature' in pkgdata['footer']:
+ raise Exception('cannot upload a package with an existing storeSignature field')
+
+ if not 'developerSignature' in pkgdata['footer']:
+ raise Exception('cannot upload a package without a developer signature')
+
+ certificates = []
+ for certFile in settings.APPSTORE_DEV_VERIFY_CA_CERTIFICATES:
+ with open(certFile, 'rb') as cert:
+ certificates.append(cert.read())
+
+ verifySignature(pkgdata['footer']['developerSignature'], pkgdata['rawDigest'], certificates)
+
+ except Exception as error:
+ raise Exception(str(error))
+
+ return pkgdata
+
+
+def addFileToPackage(sourcePackageFile, destinationPackageFile, fileName, fileContents):
+ src = tarfile.open(sourcePackageFile, mode = 'r:*', encoding = 'utf-8')
+ dst = tarfile.open(destinationPackageFile, mode = 'w:gz', encoding = 'utf-8')
+
+ for entry in src:
+ if entry.isfile():
+ dst.addfile(entry, src.extractfile(entry))
+ else:
+ dst.addfile(entry)
+
+ with tempfile.NamedTemporaryFile() as tmp:
+ tmp.write(fileContents)
+ tmp.seek(0)
+
+ entry = dst.gettarinfo(fileobj = tmp, arcname = fileName)
+ entry.uid = entry.gid = 0
+ entry.uname = entry.gname = ''
+ entry.mode = 0400
+ dst.addfile(entry, fileobj = tmp)
+
+ dst.close()
+ src.close()
+
+def addSignatureToPackage(sourcePackageFile, destinationPackageFile, digest, deviceId):
+ signingCertificate = ''
+ with open(settings.APPSTORE_STORE_SIGN_PKCS12_CERTIFICATE) as cert:
+ signingCertificate = cert.read()
+
+ digestPlusId = hmac.new(deviceId, digest, hashlib.sha256).digest();
+ signature = createSignature(digestPlusId, signingCertificate, settings.APPSTORE_STORE_SIGN_PKCS12_PASSWORD)
+
+ yamlContent = yaml.dump_all([{ 'formatVersion': 1, 'formatType': 'am-package-footer'}, { 'storeSignature': base64.encodestring(signature) }], explicit_start=True)
+
+ addFileToPackage(sourcePackageFile, destinationPackageFile, '--PACKAGE-FOOTER--store-signature', yamlContent)
+