Nodes can now be shared with /nodes/<node_id>/share endpoint.
A POST will create a short link (if it doesn't exist already), whereas a GET will return the short link for the node. The endpoint will return a dict like: {'short_code': 'XXXXXX', 'short_link': 'https://blender.cloud/r/XXXXX', 'theatre_link': 'https://blender.cloud/r/XXXXX?t'}
This commit is contained in:
parent
2bdfbaea13
commit
3f3e9ac7db
@ -263,4 +263,4 @@ latest.setup_app(app, url_prefix='/latest')
|
|||||||
blender_cloud.setup_app(app, url_prefix='/bcloud')
|
blender_cloud.setup_app(app, url_prefix='/bcloud')
|
||||||
users.setup_app(app, url_prefix='/users')
|
users.setup_app(app, url_prefix='/users')
|
||||||
service.setup_app(app, url_prefix='/service')
|
service.setup_app(app, url_prefix='/service')
|
||||||
nodes.setup_app(app)
|
nodes.setup_app(app, url_prefix='/nodes')
|
||||||
|
@ -1,15 +1,120 @@
|
|||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import urlparse
|
||||||
|
|
||||||
|
import pymongo.errors
|
||||||
|
import rsa.randnum
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
from flask import current_app, g
|
from flask import current_app, g, Blueprint, request
|
||||||
from werkzeug.exceptions import UnprocessableEntity
|
from werkzeug.exceptions import UnprocessableEntity, InternalServerError
|
||||||
|
|
||||||
from application.modules import file_storage
|
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.gcs import update_file_name
|
||||||
from application.utils.activities import activity_subscribe, activity_object_add
|
from application.utils.activities import activity_subscribe, activity_object_add
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
blueprint = Blueprint('nodes', __name__)
|
||||||
|
ROLES_FOR_SHARING = {u'subscriber', u'demo'}
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/<node_id>/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):
|
def item_parse_attachments(response):
|
||||||
@ -247,7 +352,7 @@ def nodes_set_default_picture(nodes):
|
|||||||
node_set_default_picture(node)
|
node_set_default_picture(node)
|
||||||
|
|
||||||
|
|
||||||
def setup_app(app):
|
def setup_app(app, url_prefix):
|
||||||
# Permission hooks
|
# Permission hooks
|
||||||
app.on_fetched_item_nodes += before_returning_node_permissions
|
app.on_fetched_item_nodes += before_returning_node_permissions
|
||||||
app.on_fetched_resource_nodes += before_returning_node_resource_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_deduct_content_type
|
||||||
app.on_insert_nodes += nodes_set_default_picture
|
app.on_insert_nodes += nodes_set_default_picture
|
||||||
app.on_inserted_nodes += after_inserting_nodes
|
app.on_inserted_nodes += after_inserting_nodes
|
||||||
|
|
||||||
|
app.register_blueprint(blueprint, url_prefix=url_prefix)
|
||||||
|
@ -98,3 +98,6 @@ LOGGING = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SHORT_LINK_BASE_URL = 'https://blender.cloud/r/'
|
||||||
|
SHORT_CODE_LENGTH = 6 # characters
|
||||||
|
@ -322,7 +322,13 @@ nodes_schema = {
|
|||||||
'permissions': {
|
'permissions': {
|
||||||
'type': 'dict',
|
'type': 'dict',
|
||||||
'schema': permissions_embedded_schema
|
'schema': permissions_embedded_schema
|
||||||
}
|
},
|
||||||
|
'short_codes': {
|
||||||
|
'type': 'list',
|
||||||
|
'schema': {
|
||||||
|
'type': 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens_schema = {
|
tokens_schema = {
|
||||||
|
@ -30,6 +30,7 @@ zencoder==0.6.5
|
|||||||
pytest==2.9.1
|
pytest==2.9.1
|
||||||
responses==0.5.1
|
responses==0.5.1
|
||||||
pytest-cov==2.2.1
|
pytest-cov==2.2.1
|
||||||
|
mock=2.0.0
|
||||||
|
|
||||||
# Secondary requirements
|
# Secondary requirements
|
||||||
Flask-PyMongo==0.4.1
|
Flask-PyMongo==0.4.1
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
from mock import mock
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
from eve.methods.post import post_internal
|
from eve.methods.post import post_internal
|
||||||
from eve.methods.put import put_internal
|
from eve.methods.put import put_internal
|
||||||
@ -232,3 +233,98 @@ class NodeOwnerTest(AbstractPillarTest):
|
|||||||
self.assertEqual(200, resp.status_code, resp.data)
|
self.assertEqual(200, resp.status_code, resp.data)
|
||||||
json_node = json.loads(resp.data)
|
json_node = json.loads(resp.data)
|
||||||
self.assertEqual(str(self.user_id), json_node['user'])
|
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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user