diff --git a/pillar/application/__init__.py b/pillar/application/__init__.py index bc76d834..298a9c17 100644 --- a/pillar/application/__init__.py +++ b/pillar/application/__init__.py @@ -263,4 +263,4 @@ latest.setup_app(app, url_prefix='/latest') blender_cloud.setup_app(app, url_prefix='/bcloud') users.setup_app(app, url_prefix='/users') service.setup_app(app, url_prefix='/service') -nodes.setup_app(app) +nodes.setup_app(app, url_prefix='/nodes') diff --git a/pillar/application/modules/nodes.py b/pillar/application/modules/nodes.py index 8741256f..f225d390 100644 --- a/pillar/application/modules/nodes.py +++ b/pillar/application/modules/nodes.py @@ -1,15 +1,120 @@ +import base64 import logging +import urlparse +import pymongo.errors +import rsa.randnum from bson import ObjectId -from flask import current_app, g -from werkzeug.exceptions import UnprocessableEntity +from flask import current_app, g, Blueprint, request +from werkzeug.exceptions import UnprocessableEntity, InternalServerError from application.modules import file_storage -from application.utils.authorization import check_permissions +from application.utils import str2id, jsonify +from application.utils.authorization import check_permissions, require_login from application.utils.gcs import update_file_name from application.utils.activities import activity_subscribe, activity_object_add log = logging.getLogger(__name__) +blueprint = Blueprint('nodes', __name__) +ROLES_FOR_SHARING = {u'subscriber', u'demo'} + + +@blueprint.route('//share', methods=['GET', 'POST']) +@require_login(require_roles=ROLES_FOR_SHARING) +def share_node(node_id): + """Shares a node, or returns sharing information.""" + + node_id = str2id(node_id) + nodes_coll = current_app.data.driver.db['nodes'] + + node = nodes_coll.find_one({'_id': node_id}, + projection={ + 'project': 1, + 'node_type': 1, + 'short_codes': 1 + }) + + check_permissions('nodes', node, request.method) + + log.info('Sharing node %s', node_id) + + # We support storing multiple short links in the database, but + # for now we just always store one and the same. + short_codes = node.get('short_codes', []) + if not short_codes and request.method == 'POST': + short_code = generate_and_store_short_code(node) + status = 201 + else: + try: + short_code = short_codes[0] + except IndexError: + return '', 204 + status = 200 + + return jsonify(short_link_info(short_code), status=status) + + +def generate_and_store_short_code(node): + nodes_coll = current_app.data.driver.db['nodes'] + node_id = node['_id'] + + log.debug('Creating new short link for node %s', node_id) + + max_attempts = 10 + for attempt in range(1, max_attempts): + + # Generate a new short code + short_code = create_short_code(node) + log.debug('Created short code for node %s: %s', node_id, short_code) + + node.setdefault('short_codes', []).append(short_code) + + # Store it in MongoDB + try: + result = nodes_coll.update_one({'_id': node_id}, + {'$set': {'short_codes': node['short_codes']}}) + break + except pymongo.errors.DuplicateKeyError: + node['short_codes'].remove(short_code) + + log.info('Duplicate key while creating short code, retrying (attempt %i/%i)', + attempt, max_attempts) + pass + else: + log.error('Unable to find unique short code for node %s after %i attempts, failing!', + node_id, max_attempts) + raise InternalServerError('Unable to create unique short code for node %s' % node_id) + + # We were able to store a short code, now let's verify the result. + if result.matched_count != 1: + log.warning('Unable to update node %s with new short_links=%r', + node_id, node['short_codes']) + raise InternalServerError('Unable to update node %s with new short links' % node_id) + + return short_code + + +def create_short_code(node): + """Generates a new 'short code' for the node.""" + + length = current_app.config['SHORT_CODE_LENGTH'] + bits = rsa.randnum.read_random_bits(32) + short_code = base64.b64encode(bits, altchars='xy').rstrip('=') + short_code = short_code[:length] + + return short_code + + +def short_link_info(short_code): + """Returns the short link info in a dict.""" + + short_link = urlparse.urljoin(current_app.config['SHORT_LINK_BASE_URL'], short_code) + + return { + 'short_code': short_code, + 'short_link': short_link, + 'theatre_link': urlparse.urljoin(short_link, '?t') + } def item_parse_attachments(response): @@ -247,7 +352,7 @@ def nodes_set_default_picture(nodes): node_set_default_picture(node) -def setup_app(app): +def setup_app(app, url_prefix): # Permission hooks app.on_fetched_item_nodes += before_returning_node_permissions app.on_fetched_resource_nodes += before_returning_node_resource_permissions @@ -264,3 +369,5 @@ def setup_app(app): app.on_insert_nodes += nodes_deduct_content_type app.on_insert_nodes += nodes_set_default_picture app.on_inserted_nodes += after_inserting_nodes + + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/config.py b/pillar/config.py index 08c0f148..efe3d6f7 100644 --- a/pillar/config.py +++ b/pillar/config.py @@ -98,3 +98,6 @@ LOGGING = { ], } } + +SHORT_LINK_BASE_URL = 'https://blender.cloud/r/' +SHORT_CODE_LENGTH = 6 # characters diff --git a/pillar/settings.py b/pillar/settings.py index a3219497..e578d97a 100644 --- a/pillar/settings.py +++ b/pillar/settings.py @@ -322,7 +322,13 @@ nodes_schema = { 'permissions': { 'type': 'dict', 'schema': permissions_embedded_schema - } + }, + 'short_codes': { + 'type': 'list', + 'schema': { + 'type': 'string', + }, + }, } tokens_schema = { diff --git a/requirements.txt b/requirements.txt index 364cde6e..6d0c7f0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,6 +30,7 @@ zencoder==0.6.5 pytest==2.9.1 responses==0.5.1 pytest-cov==2.2.1 +mock=2.0.0 # Secondary requirements Flask-PyMongo==0.4.1 diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 00ebde5c..02a6fe27 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -1,5 +1,6 @@ import json +from mock import mock from bson import ObjectId from eve.methods.post import post_internal from eve.methods.put import put_internal @@ -232,3 +233,98 @@ class NodeOwnerTest(AbstractPillarTest): self.assertEqual(200, resp.status_code, resp.data) json_node = json.loads(resp.data) self.assertEqual(str(self.user_id), json_node['user']) + + +class NodeSharingTest(AbstractPillarTest): + def setUp(self, **kwargs): + AbstractPillarTest.setUp(self, **kwargs) + + self.project_id, _ = self.ensure_project_exists() + self.user_id = self.create_user(groups=[ctd.EXAMPLE_ADMIN_GROUP_ID]) + self.create_valid_auth_token(self.user_id, 'token') + + # Create a node to share + resp = self.post('/nodes', 201, auth_token='token', json={ + 'project': self.project_id, + 'node_type': 'asset', + 'name': str(self), + 'properties': {}, + }) + self.node_id = resp.json()['_id'] + + def _check_share_data(self, share_data): + base_url = self.app.config['SHORT_LINK_BASE_URL'] + + self.assertEqual(6, len(share_data['short_code'])) + self.assertTrue(share_data['short_link'].startswith(base_url)) + self.assertTrue(share_data['theatre_link'].startswith(base_url)) + + def test_share_node(self): + # Share the node + resp = self.post('/nodes/%s/share' % self.node_id, auth_token='token', + expected_status=201) + share_data = resp.json() + + self._check_share_data(share_data) + + def test_get_share_data__unshared_node(self): + self.get('/nodes/%s/share' % self.node_id, + expected_status=204, + auth_token='token') + + def test_get_share_data__shared_node(self): + # Share the node first. + self.post('/nodes/%s/share' % self.node_id, auth_token='token', + expected_status=201) + + # Then get its share info. + resp = self.get('/nodes/%s/share' % self.node_id, auth_token='token') + share_data = resp.json() + + self._check_share_data(share_data) + + def test_unauthenticated(self): + self.post('/nodes/%s/share' % self.node_id, + expected_status=403) + + def test_other_user(self): + other_user_id = self.create_user(user_id=24 * 'a') + self.create_valid_auth_token(other_user_id, 'other-token') + + self.post('/nodes/%s/share' % self.node_id, + auth_token='other-token', + expected_status=403) + + def test_create_short_link(self): + from application.modules.nodes import create_short_code + + with self.app.test_request_context(): + length = self.app.config['SHORT_CODE_LENGTH'] + + # We're testing a random process, so we have to repeat it + # a few times to see if it really works. + for _ in range(10000): + short_code = create_short_code({}) + self.assertEqual(length, len(short_code)) + + def test_short_code_collision(self): + # Create a second node that has already been shared. + self.post('/nodes', 201, auth_token='token', json={ + 'project': self.project_id, + 'node_type': 'asset', + 'name': 'collider', + 'properties': {}, + 'short_codes': ['takenX'], + }) + + # Mock create_short_code so that it returns predictable short codes. + codes = ['takenX', 'takenX', 'freeXX'] + with mock.patch('application.modules.nodes.create_short_code', + side_effect=codes) as create_short_link: + resp = self.post('/nodes/%s/share' % self.node_id, auth_token='token', + expected_status=201) + + share_data = resp.json() + + self._check_share_data(share_data) + self.assertEqual(3, create_short_link.call_count)