diff options
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | README.md | 249 | ||||
-rw-r--r-- | appstore/__init__.py | 0 | ||||
-rw-r--r-- | appstore/settings.py | 102 | ||||
-rw-r--r-- | appstore/urls.py | 21 | ||||
-rw-r--r-- | appstore/wsgi.py | 14 | ||||
-rwxr-xr-x | manage.py | 10 | ||||
-rw-r--r-- | store/__init__.py | 0 | ||||
-rw-r--r-- | store/admin.py | 152 | ||||
-rw-r--r-- | store/api.py | 170 | ||||
-rw-r--r-- | store/management/__init__.py | 0 | ||||
-rw-r--r-- | store/management/commands/__init__.py | 0 | ||||
-rw-r--r-- | store/management/commands/expire-downloads.py | 25 | ||||
-rw-r--r-- | store/management/commands/store-sign-package.py | 31 | ||||
-rw-r--r-- | store/management/commands/verify-upload-package.py | 22 | ||||
-rw-r--r-- | store/migrations/0001_initial.py | 68 | ||||
-rw-r--r-- | store/migrations/__init__.py | 0 | ||||
-rw-r--r-- | store/models.py | 100 | ||||
-rw-r--r-- | store/utilities.py | 339 |
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	oa1-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) + |