server/posts: allow anonymous uploads (#90)

This commit is contained in:
rr- 2016-06-05 10:30:10 +02:00
parent a20bf56e75
commit 508cb6e7ab
5 changed files with 172 additions and 87 deletions

12
API.md
View file

@ -676,7 +676,8 @@ data.
"source": <source>, // optional "source": <source>, // optional
"relations": [<post1>, <post2>, <post3>], // optional "relations": [<post1>, <post2>, <post3>], // optional
"notes": [<note1>, <note2>, <note3>], // optional "notes": [<note1>, <note2>, <note3>], // optional
"flags": [<flag1>, <flag2>] // optional "flags": [<flag1>, <flag2>], // optional
"anonymous": <anonymous> // optional
} }
``` ```
@ -704,9 +705,12 @@ data.
found. Safety must be any of `"safe"`, `"sketchy"` or `"unsafe"`. Relations found. Safety must be any of `"safe"`, `"sketchy"` or `"unsafe"`. Relations
must contain valid post IDs. `<flag>` currently can be only `"loop"` to must contain valid post IDs. `<flag>` currently can be only `"loop"` to
enable looping for video posts. Sending empty `thumbnail` will cause the enable looping for video posts. Sending empty `thumbnail` will cause the
post to use default thumbnail. All fields are optional - update concerns post to use default thumbnail. If `anonymous` is set to truthy value, the
only provided fields. For details how to pass `content` and `thumbnail`, uploader name won't be recorded (privilege verification still applies; it's
see [file uploads](#file-uploads) for details. possible to disallow anonymous uploads completely from config.) All fields
are optional - update concerns only provided fields. For details how to
pass `content` and `thumbnail`, see [file uploads](#file-uploads) for
details.
## Updating post ## Updating post
- **Request** - **Request**

View file

@ -2,6 +2,26 @@ import falcon
from szurubooru import errors from szurubooru import errors
from szurubooru.func import net from szurubooru.func import net
def _lower_first(input):
return input[0].lower() + input[1:]
def _param_wrapper(func):
def wrapper(self, name, required=False, default=None, **kwargs):
if name in self.input:
value = self.input[name]
try:
value = func(self, value, **kwargs)
except errors.InvalidParameterError as e:
raise errors.InvalidParameterError(
'Parameter %r is invalid: %s' % (
name, _lower_first(str(e))))
return value
if not required:
return default
raise errors.MissingRequiredParameterError(
'Required parameter %r is missing.' % name)
return wrapper
class Context(object): class Context(object):
def __init__(self): def __init__(self):
self.session = None self.session = None
@ -27,57 +47,45 @@ class Context(object):
raise errors.MissingRequiredFileError( raise errors.MissingRequiredFileError(
'Required file %r is missing.' % name) 'Required file %r is missing.' % name)
def get_param_as_list(self, name, required=False, default=None): @_param_wrapper
if name in self.input: def get_param_as_list(self, value):
param = self.input[name] if not isinstance(value, list):
if not isinstance(param, list): return [value]
return [param] return value
return param
if not required:
return default
raise errors.MissingRequiredParameterError(
'Required paramter %r is missing.' % name)
def get_param_as_string(self, name, required=False, default=None): @_param_wrapper
if name in self.input: def get_param_as_string(self, value):
param = self.input[name] if isinstance(value, list):
if isinstance(param, list):
try: try:
param = ','.join(param) value = ','.join(value)
except: except:
raise errors.InvalidParameterError( raise errors.InvalidParameterError('Expected simple string.')
'Parameter %r is invalid - expected simple string.' return value
% name)
return param
if not required:
return default
raise errors.MissingRequiredParameterError(
'Required paramter %r is missing.' % name)
# pylint: disable=redefined-builtin,too-many-arguments # pylint: disable=redefined-builtin
def get_param_as_int( @_param_wrapper
self, name, required=False, min=None, max=None, default=None): def get_param_as_int(self, value, min=None, max=None):
if name in self.input:
val = self.input[name]
try: try:
val = int(val) value = int(value)
except (ValueError, TypeError): except (ValueError, TypeError):
raise errors.InvalidParameterError( raise errors.InvalidParameterError(
'Parameter %r is invalid: the value must be an integer.' 'The value must be an integer.')
% name) if min is not None and value < min:
if min is not None and val < min:
raise errors.InvalidParameterError( raise errors.InvalidParameterError(
'Parameter %r is invalid: the value must be at least %r.' 'The value must be at least %r.' % min)
% (name, min)) if max is not None and value > max:
if max is not None and val > max:
raise errors.InvalidParameterError( raise errors.InvalidParameterError(
'Parameter %r is invalid: the value may not exceed %r.' 'The value may not exceed %r.' % max)
% (name, max)) return value
return val
if not required: @_param_wrapper
return default def get_param_as_bool(self, value):
raise errors.MissingRequiredParameterError( value = str(value).lower()
'Required parameter %r is missing.' % name) if value in ['1', 'y', 'yes', 'yeah', 'yep', 'yup', 't', 'true']:
return True
if value in ['0', 'n', 'no', 'nope', 'f', 'false']:
return False
raise errors.InvalidParameterError('The value must be a boolean value.')
class Request(falcon.Request): class Request(falcon.Request):
context_type = Context context_type = Context

View file

@ -22,7 +22,11 @@ class PostListApi(BaseApi):
ctx, lambda post: _serialize_post(ctx, post)) ctx, lambda post: _serialize_post(ctx, post))
def post(self, ctx): def post(self, ctx):
auth.verify_privilege(ctx.user, 'posts:create') anonymous = ctx.get_param_as_bool('anonymous', default=False)
if anonymous:
auth.verify_privilege(ctx.user, 'posts:create:anonymous')
else:
auth.verify_privilege(ctx.user, 'posts:create:identified')
content = ctx.get_file('content', required=True) content = ctx.get_file('content', required=True)
tag_names = ctx.get_param_as_list('tags', required=True) tag_names = ctx.get_param_as_list('tags', required=True)
safety = ctx.get_param_as_string('safety', required=True) safety = ctx.get_param_as_string('safety', required=True)
@ -33,7 +37,8 @@ class PostListApi(BaseApi):
notes = ctx.get_param_as_list('notes', required=False) or [] notes = ctx.get_param_as_list('notes', required=False) or []
flags = ctx.get_param_as_list('flags', required=False) or [] flags = ctx.get_param_as_list('flags', required=False) or []
post = posts.create_post(content, tag_names, ctx.user) post = posts.create_post(
content, tag_names, None if anonymous else ctx.user)
posts.update_post_safety(post, safety) posts.update_post_safety(post, safety)
posts.update_post_source(post, source) posts.update_post_source(post, source)
posts.update_post_relations(post, relations) posts.update_post_relations(post, relations)

View file

@ -62,3 +62,40 @@ def test_getting_int_parameter():
with pytest.raises(errors.ValidationError): with pytest.raises(errors.ValidationError):
assert ctx.get_param_as_int('key', max=50) == 50 assert ctx.get_param_as_int('key', max=50) == 50
ctx.get_param_as_int('key', max=49) ctx.get_param_as_int('key', max=49)
def test_getting_bool_parameter():
def test(value):
ctx = api.Context()
ctx.input = {'key': value}
return ctx.get_param_as_bool('key')
assert test('1') is True
assert test('y') is True
assert test('yes') is True
assert test('yep') is True
assert test('yup') is True
assert test('yeah') is True
assert test('t') is True
assert test('true') is True
assert test('TRUE') is True
assert test('0') is False
assert test('n') is False
assert test('no') is False
assert test('nope') is False
assert test('f') is False
assert test('false') is False
assert test('FALSE') is False
with pytest.raises(errors.ValidationError):
test('herp')
with pytest.raises(errors.ValidationError):
test('2')
with pytest.raises(errors.ValidationError):
test(['1', '2'])
ctx = api.Context()
assert ctx.get_param_as_bool('non-existing') is None
assert ctx.get_param_as_bool('non-existing', default=True) is True
with pytest.raises(errors.ValidationError):
assert ctx.get_param_as_bool('non-existing', required=True) is None

View file

@ -8,7 +8,10 @@ from szurubooru.func import posts, tags, snapshots, net
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def inject_config(config_injector): def inject_config(config_injector):
config_injector({ config_injector({
'privileges': {'posts:create': db.User.RANK_REGULAR}, 'privileges': {
'posts:create:anonymous': db.User.RANK_REGULAR,
'posts:create:identified': db.User.RANK_REGULAR,
},
}) })
def test_creating_minimal_posts( def test_creating_minimal_posts(
@ -28,7 +31,6 @@ def test_creating_minimal_posts(
unittest.mock.patch('szurubooru.func.posts.serialize_post'), \ unittest.mock.patch('szurubooru.func.posts.serialize_post'), \
unittest.mock.patch('szurubooru.func.tags.export_to_json'), \ unittest.mock.patch('szurubooru.func.tags.export_to_json'), \
unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'): unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'):
posts.create_post.return_value = post posts.create_post.return_value = post
posts.serialize_post.return_value = 'serialized post' posts.serialize_post.return_value = 'serialized post'
@ -73,7 +75,6 @@ def test_creating_full_posts(context_factory, post_factory, user_factory):
unittest.mock.patch('szurubooru.func.posts.serialize_post'), \ unittest.mock.patch('szurubooru.func.posts.serialize_post'), \
unittest.mock.patch('szurubooru.func.tags.export_to_json'), \ unittest.mock.patch('szurubooru.func.tags.export_to_json'), \
unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'): unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'):
posts.create_post.return_value = post posts.create_post.return_value = post
posts.serialize_post.return_value = 'serialized post' posts.serialize_post.return_value = 'serialized post'
@ -104,6 +105,36 @@ def test_creating_full_posts(context_factory, post_factory, user_factory):
tags.export_to_json.assert_called_once_with() tags.export_to_json.assert_called_once_with()
snapshots.save_entity_creation.assert_called_once_with(post, auth_user) snapshots.save_entity_creation.assert_called_once_with(post, auth_user)
def test_anonymous_uploads(
config_injector, context_factory, post_factory, user_factory):
auth_user = user_factory(rank=db.User.RANK_REGULAR)
post = post_factory()
db.session.add(post)
db.session.flush()
with unittest.mock.patch('szurubooru.func.tags.export_to_json'), \
unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'), \
unittest.mock.patch('szurubooru.func.posts.serialize_post'), \
unittest.mock.patch('szurubooru.func.posts.create_post'), \
unittest.mock.patch('szurubooru.func.posts.update_post_source'):
config_injector({
'privileges': {'posts:create:anonymous': db.User.RANK_REGULAR},
})
posts.create_post.return_value = post
api.PostListApi().post(
context_factory(
input={
'safety': 'safe',
'tags': ['tag1', 'tag2'],
'anonymous': 'True',
},
files={
'content': 'post-content',
},
user=auth_user))
posts.create_post.assert_called_once_with(
'post-content', ['tag1', 'tag2'], None)
def test_creating_from_url_saves_source( def test_creating_from_url_saves_source(
config_injector, context_factory, post_factory, user_factory): config_injector, context_factory, post_factory, user_factory):
auth_user = user_factory(rank=db.User.RANK_REGULAR) auth_user = user_factory(rank=db.User.RANK_REGULAR)
@ -118,7 +149,7 @@ def test_creating_from_url_saves_source(
unittest.mock.patch('szurubooru.func.posts.create_post'), \ unittest.mock.patch('szurubooru.func.posts.create_post'), \
unittest.mock.patch('szurubooru.func.posts.update_post_source'): unittest.mock.patch('szurubooru.func.posts.update_post_source'):
config_injector({ config_injector({
'privileges': {'posts:create': db.User.RANK_REGULAR}, 'privileges': {'posts:create:identified': db.User.RANK_REGULAR},
}) })
net.download.return_value = b'content' net.download.return_value = b'content'
posts.create_post.return_value = post posts.create_post.return_value = post
@ -149,7 +180,7 @@ def test_creating_from_url_with_source_specified(
unittest.mock.patch('szurubooru.func.posts.create_post'), \ unittest.mock.patch('szurubooru.func.posts.create_post'), \
unittest.mock.patch('szurubooru.func.posts.update_post_source'): unittest.mock.patch('szurubooru.func.posts.update_post_source'):
config_injector({ config_injector({
'privileges': {'posts:create': db.User.RANK_REGULAR}, 'privileges': {'posts:create:identified': db.User.RANK_REGULAR},
}) })
net.download.return_value = b'content' net.download.return_value = b'content'
posts.create_post.return_value = post posts.create_post.return_value = post