screenshot and video handling api (bug 755218)

This commit is contained in:
Andy McKay 2012-05-23 19:13:18 +01:00
Родитель 3c41c549a6
Коммит 730937b3ac
11 изменённых файлов: 385 добавлений и 101 удалений

Просмотреть файл

@ -300,6 +300,10 @@ class AMOPaths(object):
path = 'mkt/webapps/tests/sample.key'
return os.path.join(settings.ROOT, path)
def mozball_image(self):
return os.path.join(settings.ROOT,
'mkt/developers/tests/addons/mozball-128.png')
def close_to_now(dt):
"""

Просмотреть файл

@ -96,25 +96,31 @@ def initialize_oauth_server_request(request):
"""
# Since 'Authorization' header comes through as 'HTTP_AUTHORIZATION',
# convert it back
# convert it back.
auth_header = {}
if 'HTTP_AUTHORIZATION' in request.META:
auth_header = {'Authorization': request.META.get('HTTP_AUTHORIZATION')}
url = urljoin(settings.SITE_URL, request.path)
# Note: we are only signing using the QUERY STRING. We are not signing the
# body yet. According to the spec we should be including an oauth_body_hash
# as per:
#
# http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/drafts/1/spec.html
#
# There is no support in python-oauth2 for this yet. There is an
# outstanding pull request for this:
#
# https://github.com/simplegeo/python-oauth2/pull/110
#
# Or time to move to a better OAuth implementation.
oauth_request = oauth2.Request.from_request(
request.method, url, headers=auth_header)
if oauth_request:
oauth_server = oauth2.Server(signature_methods={
# Supported signature methods
'HMAC-SHA1': oauth2.SignatureMethod_HMAC_SHA1()
})
else:
oauth_server = None
request.method, url, headers=auth_header,
query_string=request.META['QUERY_STRING'])
oauth_server = oauth2.Server(signature_methods={
'HMAC-SHA1': oauth2.SignatureMethod_HMAC_SHA1()
})
return oauth_server, oauth_request

Просмотреть файл

@ -1,13 +1,14 @@
import json
from django.core.exceptions import ObjectDoesNotExist
from tastypie import http
from tastypie.bundle import Bundle
from tastypie.exceptions import ImmediateHttpResponse
from tastypie.exceptions import ImmediateHttpResponse, NotFound
from tastypie.resources import ModelResource
from translations.fields import PurifiedField, TranslatedField
class MarketplaceResource(ModelResource):
def get_resource_uri(self, bundle_or_obj):
@ -49,3 +50,25 @@ class MarketplaceResource(ModelResource):
response = http.HttpBadRequest(json.dumps({'error_message': errors}),
content_type='application/json')
return ImmediateHttpResponse(response=response)
def get_object_or_404(self, cls, **filters):
"""
A wrapper around our more familiar get_object_or_404, for when we need
to get access to an object that isn't covered by get_obj.
"""
if not filters:
raise ImmediateHttpResponse(response=http.HttpNotFound())
try:
return cls.objects.get(**filters)
except (cls.DoesNotExist, cls.MultipleObjectsReturned):
raise ImmediateHttpResponse(response=http.HttpNotFound())
def get_by_resource_or_404(self, request, **kwargs):
"""
A wrapper around the obj_get to just get the object.
"""
try:
obj = self.obj_get(request, **kwargs)
except ObjectDoesNotExist:
raise ImmediateHttpResponse(response=http.HttpNotFound())
return obj

Просмотреть файл

@ -1,8 +1,13 @@
import base64
import json
import StringIO
from django import forms
import happyforms
from files.models import FileUpload
from mkt.developers.utils import check_upload
class UploadForm(happyforms.Form):
@ -19,3 +24,41 @@ class UploadForm(happyforms.Form):
self.obj = upload
return uuid
class JSONField(forms.Field):
def to_python(self, value):
if value == '':
return None
try:
if isinstance(value, basestring):
return json.loads(value)
except ValueError:
pass
return value
class PreviewJSONForm(happyforms.Form):
file = JSONField(required=True)
position = forms.IntegerField(required=True)
def clean_file(self):
file_ = self.cleaned_data.get('file', {})
try:
if not set(['data', 'type']).issubset(set(file_.keys())):
raise forms.ValidationError('Type and data are required.')
except AttributeError:
raise forms.ValidationError('File must be a dictionary.')
file_obj = StringIO.StringIO(base64.b64decode(file_['data']))
errors, hash_ = check_upload(file_obj, 'image', file_['type'])
if errors:
raise forms.ValidationError(errors)
self.hash_ = hash_
return file_
def clean(self):
self.cleaned_data['upload_hash'] = getattr(self, 'hash_', None)
return self.cleaned_data

Просмотреть файл

@ -5,10 +5,12 @@ from django.db import transaction
import commonware.log
from tastypie import http
from tastypie.exceptions import ImmediateHttpResponse
from tastypie import fields
from tastypie.serializers import Serializer
from tastypie.resources import ALL_WITH_RELATIONS
from addons.forms import CategoryFormSet, DeviceTypeForm
from addons.models import AddonUser, Category, DeviceType
from addons.models import AddonUser, Category, DeviceType, Preview
import amo
from amo.decorators import write
from amo.utils import no_translation
@ -19,10 +21,11 @@ from mkt.api.base import MarketplaceResource
from mkt.api.forms import UploadForm
from mkt.developers import tasks
from mkt.developers.forms import NewManifestForm
from mkt.developers.forms import PreviewForm
from mkt.api.forms import PreviewJSONForm
from mkt.webapps.models import Webapp
from mkt.submit.forms import AppDetailsBasicForm
log = commonware.log.getLogger('z.api')
@ -64,7 +67,7 @@ class ValidationResource(MarketplaceResource):
raise ImmediateHttpResponse(response=http.HttpNotFound())
if not OwnerAuthorization().is_authorized(request, object=obj):
raise ImmediateHttpResponse(response=http.HttpUnauthorized())
raise ImmediateHttpResponse(response=http.HttpForbidden())
log.info('Validation retreived: %s' % obj.pk)
return obj
@ -105,7 +108,7 @@ class AppResource(MarketplaceResource):
if not (OwnerAuthorization()
.is_authorized(request, object=form.obj)):
raise ImmediateHttpResponse(response=http.HttpUnauthorized())
raise ImmediateHttpResponse(response=http.HttpForbidden())
plats = [Platform.objects.get(id=amo.PLATFORM_ALL.id)]
@ -119,7 +122,7 @@ class AppResource(MarketplaceResource):
def obj_get(self, request=None, **kwargs):
obj = super(AppResource, self).obj_get(request=request, **kwargs)
if not AppOwnerAuthorization().is_authorized(request, object=obj):
raise ImmediateHttpResponse(response=http.HttpUnauthorized())
raise ImmediateHttpResponse(response=http.HttpForbidden())
log.info('App retreived: %s' % obj.pk)
return obj
@ -148,7 +151,7 @@ class AppResource(MarketplaceResource):
raise ImmediateHttpResponse(response=http.HttpNotFound())
if not AppOwnerAuthorization().is_authorized(request, object=obj):
raise ImmediateHttpResponse(response=http.HttpUnauthorized())
raise ImmediateHttpResponse(response=http.HttpForbidden())
data['slug'] = data.get('slug', obj.app_slug)
data.update(self.formset(data))
@ -191,3 +194,62 @@ class CategoryResource(MarketplaceResource):
always_return_data = True
resource_name = 'category'
serializer = Serializer(formats=['json'])
class PreviewResource(MarketplaceResource):
addon = fields.ForeignKey(AppResource, 'addon')
class Meta:
queryset = Preview.objects.all()
list_allowed_methods = ['post']
allowed_methods = ['get', 'delete']
always_return_data = True
fields = ['id']
authentication = MarketplaceAuthentication()
authorization = OwnerAuthorization()
resource_name = 'preview'
filtering = {'addon': ALL_WITH_RELATIONS}
def obj_create(self, bundle, request, **kwargs):
filters = self.build_filters(filters=request.GET.copy())
addon = self.get_object_or_404(Webapp,
pk=filters.get('addon__exact'))
if not AppOwnerAuthorization().is_authorized(request, object=addon):
raise ImmediateHttpResponse(response=http.HttpForbidden())
data_form = PreviewJSONForm(bundle.data)
if not data_form.is_valid():
raise self.form_errors(data_form)
form = PreviewForm(data_form.cleaned_data)
if not form.is_valid():
raise self.form_errors(form)
form.save(addon)
bundle.obj = form.instance
log.info('Preview created: %s' % bundle.obj.pk)
return bundle
def obj_delete(self, request, **kwargs):
obj = self.get_by_resource_or_404(request, **kwargs)
if not AppOwnerAuthorization().is_authorized(request,
object=obj.addon):
raise ImmediateHttpResponse(response=http.HttpForbidden())
log.info('Preview deleted: %s' % obj.pk)
return super(PreviewResource, self).obj_delete(request, **kwargs)
def obj_get(self, request=None, **kwargs):
obj = super(PreviewResource, self).obj_get(request=request, **kwargs)
if not AppOwnerAuthorization().is_authorized(request,
object=obj.addon):
raise ImmediateHttpResponse(response=http.HttpForbidden())
log.info('Preview retreived: %s' % obj.pk)
return obj
def dehydrate(self, bundle):
# Returning an image back to the user isn't useful, let's stop that.
if 'file' in bundle.data:
del bundle.data['file']
return bundle

Просмотреть файл

@ -0,0 +1,48 @@
import base64
from nose.tools import eq_
import amo
import amo.tests
from mkt.api.forms import PreviewJSONForm
class TestPreviewForm(amo.tests.TestCase, amo.tests.AMOPaths):
def setUp(self):
self.file = base64.b64encode(open(self.mozball_image(), 'r').read())
def test_bad_type(self):
form = PreviewJSONForm({'file': {'data': self.file, 'type': 'wtf?'},
'position': 1})
assert not form.is_valid()
eq_(form.errors['file'], ['Images must be either PNG or JPG.'])
def test_bad_file(self):
file_ = base64.b64encode(open(self.xpi_path('langpack'), 'r').read())
form = PreviewJSONForm({'file': {'data': file_, 'type': 'image/png'},
'position': 1})
assert not form.is_valid()
eq_(form.errors['file'], ['Images must be either PNG or JPG.'])
def test_position_missing(self):
form = PreviewJSONForm({'file': {'data': self.file,
'type': 'image/jpg'}})
assert not form.is_valid()
eq_(form.errors['position'], ['This field is required.'])
def test_preview(self):
form = PreviewJSONForm({'file': {'type': '', 'data': ''},
'position': 1})
assert not form.is_valid()
eq_(form.errors['file'], ['Images must be either PNG or JPG.'])
def test_not_json(self):
form = PreviewJSONForm({'file': 1, 'position': 1})
assert not form.is_valid()
eq_(form.errors['file'], ['File must be a dictionary.'])
def test_not_file(self):
form = PreviewJSONForm({'position': 1})
assert not form.is_valid()
eq_(form.errors['file'], ['This field is required.'])

Просмотреть файл

@ -1,3 +1,4 @@
import base64
import json
import tempfile
@ -6,7 +7,7 @@ from django.conf import settings
from mock import patch
from nose.tools import eq_
from addons.models import Addon, Category, DeviceType
from addons.models import Addon, AddonUser, Category, DeviceType
import amo
from amo.tests import AMOPaths
from files.models import FileUpload
@ -92,7 +93,7 @@ class TestGetValidationHandler(ValidationHandler):
obj = self.create()
obj.update(user=UserProfile.objects.get(email='admin@mozilla.com'))
res = self.client.get(self.get_url)
eq_(res.status_code, 401)
eq_(res.status_code, 403)
def test_not_found(self):
url = ('api_dispatch_detail',
@ -150,9 +151,6 @@ class CreateHandler(BaseOAuth):
return FileUpload.objects.create(user=self.user, path=self.file,
name=self.file, valid=True)
def get_error(self, response):
return json.loads(response.content)['error_message']
@patch.object(settings, 'SITE_URL', 'http://api/')
class TestAppCreateHandler(CreateHandler, AMOPaths):
@ -188,7 +186,7 @@ class TestAppCreateHandler(CreateHandler, AMOPaths):
obj.update(user=UserProfile.objects.get(email='admin@mozilla.com'))
res = self.client.post(self.list_url,
data=json.dumps({'manifest': obj.uuid}))
eq_(res.status_code, 401)
eq_(res.status_code, 403)
eq_(self.count(), 0)
def test_create(self):
@ -225,7 +223,7 @@ class TestAppCreateHandler(CreateHandler, AMOPaths):
obj = self.create_app()
obj.authors.clear()
res = self.client.get(self.get_url)
eq_(res.status_code, 401)
eq_(res.status_code, 403)
def base_data(self):
return {'support_email': 'a@a.com',
@ -299,7 +297,7 @@ class TestAppCreateHandler(CreateHandler, AMOPaths):
obj = self.create_app()
obj.authors.clear()
res = self.client.put(self.get_url, data='{}')
eq_(res.status_code, 401)
eq_(res.status_code, 403)
def test_put_not_there(self):
url = ('api_dispatch_detail', {'resource_name': 'app', 'pk': 123})
@ -356,3 +354,88 @@ class TestCategoryHandler(BaseOAuth):
{'resource_name': 'category',
'pk': self.other.pk}))
eq_(res.status_code, 404)
@patch.object(settings, 'SITE_URL', 'http://api/')
class TestPreviewHandler(BaseOAuth, AMOPaths):
fixtures = ['base/users', 'base/user_2519', 'webapps/337141-steamcube']
def setUp(self):
super(TestPreviewHandler, self).setUp()
self.app = Webapp.objects.get(pk=337141)
self.user = UserProfile.objects.get(pk=2519)
AddonUser.objects.create(user=self.user, addon=self.app)
self.file = base64.b64encode(open(self.mozball_image(), 'r').read())
self.list_url = ('api_dispatch_list', {'resource_name': 'preview'},
{'addon__exact': self.app.pk})
self.good = {'file': {'data': self.file, 'type': 'image/jpg'},
'position': 1}
def test_no_addon(self):
list_url = ('api_dispatch_list', {'resource_name': 'preview'})
res = self.client.post(list_url, data=json.dumps(self.good))
eq_(res.status_code, 404)
def test_post_preview(self):
res = self.client.post(self.list_url, data=json.dumps(self.good))
eq_(res.status_code, 201)
previews = self.app.previews
eq_(previews.count(), 1)
eq_(previews.all()[0].position, 1)
def test_not_mine(self):
self.app.authors.clear()
res = self.client.post(self.list_url, data=json.dumps(self.good))
eq_(res.status_code, 403)
def test_position_missing(self):
data = {'file': {'data': self.file, 'type': 'image/jpg'}}
res = self.client.post(self.list_url, data=json.dumps(data))
eq_(res.status_code, 400)
eq_(self.get_error(res)['position'], ['This field is required.'])
def test_preview_missing(self):
res = self.client.post(self.list_url, data=json.dumps({}))
eq_(res.status_code, 400)
eq_(self.get_error(res)['position'], ['This field is required.'])
def create(self):
self.client.post(self.list_url, data=json.dumps(self.good))
self.preview = self.app.previews.all()[0]
self.get_url = ('api_dispatch_detail',
{'resource_name': 'preview', 'pk': self.preview.pk})
def test_delete(self):
self.create()
res = self.client.delete(self.get_url)
eq_(res.status_code, 204)
eq_(self.app.previews.count(), 0)
def test_delete_not_mine(self):
self.create()
self.app.authors.clear()
res = self.client.delete(self.get_url)
eq_(res.status_code, 403)
def test_delete_not_there(self):
self.get_url = ('api_dispatch_detail',
{'resource_name': 'preview', 'pk': 123})
res = self.client.delete(self.get_url)
eq_(res.status_code, 404)
def test_get(self):
self.create()
res = self.client.get(self.get_url)
eq_(res.status_code, 200)
def test_get_not_mine(self):
self.create()
self.app.authors.clear()
res = self.client.get(self.get_url)
eq_(res.status_code, 403)
def test_get_not_there(self):
self.get_url = ('api_dispatch_detail',
{'resource_name': 'preview', 'pk': 123})
res = self.client.get(self.get_url)
eq_(res.status_code, 404)

Просмотреть файл

@ -19,7 +19,7 @@ With headers:
oauth_nonce="1008F707-37E6-4ABF-8322-C6B658771D88",
oauth_version="1.0"
"""
import time
import json
import urllib
import urlparse
@ -38,16 +38,6 @@ from amo.urlresolvers import reverse
from files.models import FileUpload
def _get_args(consumer):
return dict(
oauth_consumer_key=consumer.key,
oauth_nonce=oauth.generate_nonce(),
oauth_signature_method='HMAC-SHA1',
oauth_timestamp=int(time.time()),
oauth_version='1.0',
)
def get_absolute_url(url):
# TODO (andym): make this more standard.
url[1]['api_name'] = 'apps'
@ -58,13 +48,6 @@ def get_absolute_url(url):
return res
def data_keys(d):
# Form keys and values MUST be part of the signature.
# File keys MUST be part of the signature.
# But file values MUST NOT be included as part of the signature.
return dict([k, '' if isinstance(v, file) else v] for k, v in d.items())
class OAuthClient(Client):
"""
OAuthClient can do all the requests the Django test client,
@ -80,8 +63,15 @@ class OAuthClient(Client):
def header(self, method, url):
if not self.consumer:
return None
req = oauth.Request(method=method, url=url,
parameters=_get_args(self.consumer))
parsed = urlparse.urlparse(url)
args = dict(urlparse.parse_qs(parsed.query))
req = oauth.Request.from_consumer_and_token(self.consumer,
token=None, http_method=method,
http_url=urlparse.urlunparse(parsed._replace(query='')),
parameters=args)
req.sign_request(self.signature_method, self.consumer, None)
return req.to_header()['Authorization']
@ -152,8 +142,11 @@ class BaseOAuth(TestCase):
if verb in allowed:
continue
res = getattr(self.client, verb)(url)
assert (res.status_code in (401, 405),
'%s: %s not 401 or 405' % (verb.upper(), res.status_code))
assert res.status_code in (401, 405), (
'%s: %s not 401 or 405' % (verb.upper(), res.status_code))
def get_error(self, response):
return json.loads(response.content)['error_message']
@patch.object(settings, 'SITE_URL', 'http://api/')

Просмотреть файл

@ -1,14 +1,16 @@
from django.conf.urls.defaults import include, patterns, url
from tastypie.api import Api
from mkt.api.resources import AppResource, CategoryResource, ValidationResource
from mkt.search.api import SearchResource
from mkt.api.resources import (AppResource, CategoryResource,
PreviewResource, ValidationResource)
api = Api(api_name='apps')
api.register(ValidationResource())
api.register(AppResource())
api.register(CategoryResource())
api.register(SearchResource())
api.register(PreviewResource())
urlpatterns = patterns('',
url(r'^', include(api.urls)),

65
mkt/developers/utils.py Normal file
Просмотреть файл

@ -0,0 +1,65 @@
import os
import uuid
from django.conf import settings
from django.core.files.storage import default_storage as storage
from django.template.defaultfilters import filesizeformat
from tower import ugettext as _
import waffle
import amo
from lib.video import library as video_library
def check_upload(file_obj, upload_type, content_type):
errors = []
upload_hash = ''
is_icon = upload_type == 'icon'
is_video = (content_type in amo.VIDEO_TYPES and
waffle.switch_is_active('video-upload'))
# By pushing the type onto the instance hash, we can easily see what
# to do with the file later.
ext = content_type.replace('/', '-')
upload_hash = '%s.%s' % (uuid.uuid4().hex, ext)
loc = os.path.join(settings.TMP_PATH, upload_type, upload_hash)
with storage.open(loc, 'wb') as fd:
for chunk in file_obj:
fd.write(chunk)
if is_video:
if not video_library:
errors.append(_('Video support not enabled.'))
else:
video = video_library(loc)
video.get_meta()
if not video.is_valid():
errors.extend(video.errors)
else:
check = amo.utils.ImageCheck(file_obj)
if (not check.is_image() or
content_type not in amo.IMG_TYPES):
if is_icon:
errors.append(_('Icons must be either PNG or JPG.'))
else:
errors.append(_('Images must be either PNG or JPG.'))
if check.is_animated():
if is_icon:
errors.append(_('Icons cannot be animated.'))
else:
errors.append(_('Images cannot be animated.'))
max_size = (settings.MAX_ICON_UPLOAD_SIZE if is_icon else
settings.MAX_VIDEO_UPLOAD_SIZE if is_video else None)
if max_size and file_obj.size > max_size:
if is_icon or is_video:
errors.append(_('Please use files smaller than %dMB.') %
filesizeformat(max_size))
return errors, upload_hash

Просмотреть файл

@ -2,10 +2,8 @@ import json
import os
import sys
import traceback
import uuid
from django import http
from django.core.files.storage import default_storage as storage
from django.conf import settings
from django import forms as django_forms
from django.db import models, transaction
@ -26,7 +24,6 @@ import amo
import amo.utils
from amo import messages
from amo.decorators import json_view, login_required, post_required, write
from amo.helpers import loc
from amo.utils import escape_all
from amo.urlresolvers import reverse
from addons import forms as addon_forms
@ -34,7 +31,6 @@ from addons.decorators import can_become_premium
from addons.models import Addon, AddonUser
from addons.views import BaseFilter
from devhub.models import AppLog
from lib.video import library as video_library
from files.models import File, FileUpload
from files.utils import parse_addon
from market.models import AddonPaymentData, AddonPremium, Refund
@ -51,7 +47,8 @@ from mkt.developers.decorators import dev_required
from mkt.developers.forms import (AppFormBasic, AppFormDetails, AppFormMedia,
AppFormSupport, CurrencyForm,
InappConfigForm, PaypalSetupForm,
PreviewForm, PreviewFormSet, trap_duplicate)
PreviewFormSet, trap_duplicate)
from mkt.developers.utils import check_upload
from mkt.inapp_pay.models import InappConfig
from mkt.webapps.tasks import update_manifests
from mkt.webapps.models import Webapp
@ -943,52 +940,10 @@ def ajax_upload_media(request, upload_type):
if 'upload_image' in request.FILES:
upload_preview = request.FILES['upload_image']
upload_preview.seek(0)
content_type = upload_preview.content_type
errors, upload_hash = check_upload(upload_preview, upload_type,
content_type)
is_icon = upload_type == 'icon'
is_video = (upload_preview.content_type in amo.VIDEO_TYPES and
waffle.switch_is_active('video-upload'))
# By pushing the type onto the instance hash, we can easily see what
# to do with the file later.
ext = upload_preview.content_type.replace('/', '-')
upload_hash = '%s.%s' % (uuid.uuid4().hex, ext)
loc = os.path.join(settings.TMP_PATH, upload_type, upload_hash)
with storage.open(loc, 'wb') as fd:
for chunk in upload_preview:
fd.write(chunk)
if is_video:
if not video_library:
errors.append(_('Video support not enabled.'))
else:
video = video_library(loc)
video.get_meta()
if not video.is_valid():
errors.extend(video.errors)
else:
check = amo.utils.ImageCheck(upload_preview)
if (not check.is_image() or
upload_preview.content_type not in amo.IMG_TYPES):
if is_icon:
errors.append(_('Icons must be either PNG or JPG.'))
else:
errors.append(_('Images must be either PNG or JPG.'))
if check.is_animated():
if is_icon:
errors.append(_('Icons cannot be animated.'))
else:
errors.append(_('Images cannot be animated.'))
max_size = (settings.MAX_ICON_UPLOAD_SIZE if is_icon else
settings.MAX_VIDEO_UPLOAD_SIZE if is_video else None)
if max_size and upload_preview.size > max_size:
if is_icon or is_video:
errors.append(_('Please use files smaller than %dMB.') % (
max_size / 1024 / 1024 - 1))
else:
errors.append(_('There was an error uploading your preview.'))