diff --git a/mypy.ini b/mypy.ini --- a/mypy.ini +++ b/mypy.ini @@ -12,6 +12,9 @@ [mypy-bs4.*] ignore_missing_imports = True +[mypy-corsheaders.*] +ignore_missing_imports = True + [mypy-django_js_reverse.*] ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ # Runtime dependencies beautifulsoup4 Django >= 1.11.0, < 2.0 +django-cors-headers djangorestframework >= 3.4.0 django_webpack_loader django_js_reverse @@ -18,6 +19,7 @@ pyyaml requests python-memcached +pybadges # Doc dependencies sphinx diff --git a/swh/web/misc/badges.py b/swh/web/misc/badges.py new file mode 100644 --- /dev/null +++ b/swh/web/misc/badges.py @@ -0,0 +1,173 @@ +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from base64 import b64encode +from typing import cast, Optional + +from django.conf.urls import url +from django.contrib.staticfiles import finders +from django.http import HttpResponse, HttpRequest + +from pybadges import badge + +from swh.model.exceptions import ValidationError +from swh.model.identifiers import ( + persistent_identifier, parse_persistent_identifier, + CONTENT, DIRECTORY, ORIGIN, RELEASE, REVISION, SNAPSHOT +) +from swh.web.common import service +from swh.web.common.exc import BadInputExc, NotFoundExc +from swh.web.common.utils import reverse, resolve_swh_persistent_id + + +_orange = '#f36a24' +_yellow = '#fac11f' +_red = '#cd5741' + +_swh_logo_data = None + +_badge_config = { + CONTENT: { + 'color': _yellow, + 'title': 'Archived source file', + }, + DIRECTORY: { + 'color': _yellow, + 'title': 'Archived source tree', + }, + ORIGIN: { + 'color': _orange, + 'title': 'Archived software repository', + }, + RELEASE: { + 'color': _yellow, + 'title': 'Archived software release', + }, + REVISION: { + 'color': _yellow, + 'title': 'Archived commit', + }, + SNAPSHOT: { + 'color': _yellow, + 'title': 'Archived software repository snapshot', + }, + 'error': { + 'color': _red, + 'title': 'An error occurred when generating the badge' + } +} + + +def _get_logo_data() -> str: + """ + Get data-URI for Software Heritage SVG logo to embed it in + the generated badges. + """ + global _swh_logo_data + if _swh_logo_data is None: + swh_logo_path = cast(str, finders.find('img/swh-logo-white.svg')) + with open(swh_logo_path, 'rb') as swh_logo_file: + _swh_logo_data = ('data:image/svg+xml;base64,%s' % + b64encode(swh_logo_file.read()).decode('ascii')) + return _swh_logo_data + + +def _swh_badge(request: HttpRequest, object_type: str, object_id: str, + object_pid: Optional[str] = '') -> HttpResponse: + """ + Generate a Software Heritage badge for a given object type and id. + + Args: + request: input http request + object_type: The type of swh object to generate a badge for, + either *content*, *directory*, *revision*, *release*, *origin* + or *snapshot* + object_id: The id of the swh object, either an url for origin + type or a *sha1* for other object types + object_pid: If provided, the object persistent + identifier will not be recomputed + + Returns: + HTTP response with content type *image/svg+xml* containing the SVG + badge data. If the provided parameters are invalid, HTTP 400 status + code will be returned. If the object can not be found in the archive, + HTTP 404 status code will be returned. + + """ + left_text = 'error' + whole_link = '' + status = 200 + + try: + if object_type == ORIGIN: + service.lookup_origin({'url': object_id}) + right_text = 'repository' + whole_link = reverse('browse-origin', + url_args={'origin_url': object_id}) + else: + # when pid is provided, object type and id will be parsed + # from it + if object_pid: + parsed_pid = parse_persistent_identifier(object_pid) + object_type = parsed_pid.object_type + object_id = parsed_pid.object_id + swh_object = service.lookup_object(object_type, object_id) + if object_pid: + right_text = object_pid + else: + right_text = persistent_identifier(object_type, object_id) + + whole_link = resolve_swh_persistent_id(right_text)['browse_url'] + # remove pid metadata if any for badge text + if object_pid: + right_text = right_text.split(';')[0] + # use release name for badge text + if object_type == RELEASE: + right_text = 'release %s' % swh_object['name'] + left_text = 'archived' + except (BadInputExc, ValidationError): + right_text = f'invalid {object_type if object_type else "object"} id' + status = 400 + object_type = 'error' + except NotFoundExc: + right_text = f'{object_type if object_type else "object"} not found' + status = 404 + object_type = 'error' + + badge_data = badge(left_text=left_text, + right_text=right_text, + right_color=_badge_config[object_type]['color'], + whole_link=request.build_absolute_uri(whole_link), + whole_title=_badge_config[object_type]['title'], + logo=_get_logo_data(), + embed_logo=True) + + return HttpResponse(badge_data, content_type='image/svg+xml', + status=status) + + +def _swh_badge_pid(request: HttpRequest, object_pid: str) -> HttpResponse: + """ + Generate a Software Heritage badge for a given object persistent + identifier. + + Args: + request (django.http.HttpRequest): input http request + object_pid (str): A swh object persistent identifier + + Returns: + django.http.HttpResponse: An http response with content type + *image/svg+xml* containing the SVG badge data. If any error + occurs, a status code of 400 will be returned. + """ + return _swh_badge(request, '', '', object_pid) + + +urlpatterns = [ + url(r'^badge/(?P[a-z]+)/(?P.+)/$', _swh_badge, + name='swh-badge'), + url(r'^badge/(?Pswh:[0-9]+:[a-z]+:[0-9a-f]+.*)/$', + _swh_badge_pid, name='swh-badge-pid'), +] diff --git a/swh/web/misc/urls.py b/swh/web/misc/urls.py --- a/swh/web/misc/urls.py +++ b/swh/web/misc/urls.py @@ -45,6 +45,7 @@ url(r'^jslicenses/$', _jslicenses, name='jslicenses'), url(r'^', include('swh.web.misc.origin_save')), url(r'^stat_counters', _stat_counters, name='stat-counters'), + url(r'^', include('swh.web.misc.badges')), ] diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -43,12 +43,14 @@ 'swh.web.api', 'swh.web.browse', 'webpack_loader', - 'django_js_reverse' + 'django_js_reverse', + 'corsheaders' ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', @@ -277,3 +279,6 @@ } JS_REVERSE_JS_MINIFY = False + +CORS_ORIGIN_ALLOW_ALL = True +CORS_URLS_REGEX = r'^/badge/.*$' diff --git a/swh/web/static/img/swh-logo-white.svg b/swh/web/static/img/swh-logo-white.svg new file mode 100644 --- /dev/null +++ b/swh/web/static/img/swh-logo-white.svg @@ -0,0 +1,237 @@ + + + +image/svg+xml + + + + + + + + + + \ No newline at end of file diff --git a/swh/web/tests/misc/test_badges.py b/swh/web/tests/misc/test_badges.py new file mode 100644 --- /dev/null +++ b/swh/web/tests/misc/test_badges.py @@ -0,0 +1,166 @@ +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from corsheaders.middleware import ACCESS_CONTROL_ALLOW_ORIGIN +from hypothesis import given + +from swh.model.identifiers import ( + persistent_identifier, + CONTENT, DIRECTORY, ORIGIN, RELEASE, REVISION, SNAPSHOT +) +from swh.web.common import service +from swh.web.common.utils import reverse, resolve_swh_persistent_id +from swh.web.misc.badges import _badge_config, _get_logo_data +from swh.web.tests.django_asserts import assert_contains +from swh.web.tests.strategies import ( + content, directory, origin, release, revision, snapshot, + unknown_content, unknown_directory, new_origin, unknown_release, + unknown_revision, unknown_snapshot, invalid_sha1 +) + + +@given(content()) +def test_content_badge(client, content): + _test_badge_endpoints(client, CONTENT, content['sha1_git']) + + +@given(directory()) +def test_directory_badge(client, directory): + _test_badge_endpoints(client, DIRECTORY, directory) + + +@given(origin()) +def test_origin_badge(client, origin): + _test_badge_endpoints(client, ORIGIN, origin['url']) + + +@given(release()) +def test_release_badge(client, release): + _test_badge_endpoints(client, RELEASE, release) + + +@given(revision()) +def test_revision_badge(client, revision): + _test_badge_endpoints(client, REVISION, revision) + + +@given(snapshot()) +def test_snapshot_badge(client, snapshot): + _test_badge_endpoints(client, SNAPSHOT, snapshot) + + +@given(unknown_content(), unknown_directory(), new_origin(), + unknown_release(), unknown_revision(), unknown_snapshot(), + invalid_sha1()) +def test_badge_errors(client, unknown_content, unknown_directory, new_origin, + unknown_release, unknown_revision, unknown_snapshot, + invalid_sha1): + for object_type, object_id in ( + (CONTENT, unknown_content['sha1_git']), + (DIRECTORY, unknown_directory), + (ORIGIN, new_origin['url']), + (RELEASE, unknown_release), + (REVISION, unknown_revision), + (SNAPSHOT, unknown_snapshot) + ): + url_args = { + 'object_type': object_type, + 'object_id': object_id + } + url = reverse('swh-badge', url_args=url_args) + resp = client.get(url) + _check_generated_badge(resp, 404, **url_args) + + if object_type != ORIGIN: + object_pid = persistent_identifier(object_type, object_id) + url = reverse('swh-badge-pid', + url_args={'object_pid': object_pid}) + resp = client.get(url) + _check_generated_badge(resp, 404, **url_args) + + for object_type, object_id in ( + (CONTENT, invalid_sha1), + (DIRECTORY, invalid_sha1), + (RELEASE, invalid_sha1), + (REVISION, invalid_sha1), + (SNAPSHOT, invalid_sha1) + ): + url_args = { + 'object_type': object_type, + 'object_id': object_id + } + url = reverse('swh-badge', url_args=url_args) + resp = client.get(url) + _check_generated_badge(resp, 400, **url_args) + + object_pid = f'swh:1:{object_type[:3]}:{object_id}' + url = reverse('swh-badge-pid', + url_args={'object_pid': object_pid}) + resp = client.get(url) + _check_generated_badge(resp, 400, '', '') + + +@given(origin(), release()) +def test_badge_endpoints_have_cors_header(client, origin, release): + url = reverse('swh-badge', url_args={'object_type': ORIGIN, + 'object_id': origin['url']}) + resp = client.get(url, HTTP_ORIGIN='https://example.org') + assert resp.status_code == 200, resp.content + assert ACCESS_CONTROL_ALLOW_ORIGIN in resp + + release_pid = persistent_identifier(RELEASE, release) + url = reverse('swh-badge-pid', url_args={'object_pid': release_pid}) + resp = client.get(url, HTTP_ORIGIN='https://example.org') + assert resp.status_code == 200, resp.content + assert ACCESS_CONTROL_ALLOW_ORIGIN in resp + + +def _test_badge_endpoints(client, object_type, object_id): + url_args = {'object_type': object_type, + 'object_id': object_id} + url = reverse('swh-badge', url_args=url_args) + resp = client.get(url) + _check_generated_badge(resp, 200, **url_args) + if object_type != ORIGIN: + pid = persistent_identifier(object_type, object_id) + url = reverse('swh-badge-pid', url_args={'object_pid': pid}) + resp = client.get(url) + _check_generated_badge(resp, 200, **url_args) + + +def _check_generated_badge(response, status_code, object_type, object_id): + assert response.status_code == status_code, response.content + assert response['Content-Type'] == 'image/svg+xml' + + if not object_type: + object_type = 'object' + + if object_type == ORIGIN and status_code == 200: + link = reverse('browse-origin', url_args={'origin_url': object_id}) + text = 'repository' + elif status_code == 200: + text = persistent_identifier(object_type, object_id) + link = resolve_swh_persistent_id(text)['browse_url'] + if object_type == RELEASE: + release = service.lookup_release(object_id) + text = release['name'] + elif status_code == 400: + text = 'error' + link = f'invalid {object_type} id' + object_type = 'error' + elif status_code == 404: + text = 'error' + link = f'{object_type} not found' + object_type = 'error' + + assert_contains(response, '', status_code=status_code) + assert_contains(response, _get_logo_data(), status_code=status_code) + assert_contains(response, _badge_config[object_type]['color'], + status_code=status_code) + assert_contains(response, _badge_config[object_type]['title'], + status_code=status_code) + assert_contains(response, text, status_code=status_code) + assert_contains(response, link, status_code=status_code)