diff --git a/API.md b/API.md index 1a6a6d9..db89baf 100644 --- a/API.md +++ b/API.md @@ -676,7 +676,8 @@ data. "source": , // optional "relations": [, , ], // optional "notes": [, , ], // optional - "flags": [, ] // optional + "flags": [, ], // optional + "anonymous": // optional } ``` @@ -704,9 +705,12 @@ data. found. Safety must be any of `"safe"`, `"sketchy"` or `"unsafe"`. Relations must contain valid post IDs. `` currently can be only `"loop"` to enable looping for video posts. Sending empty `thumbnail` will cause the - post to use default thumbnail. All fields are optional - update concerns - only provided fields. For details how to pass `content` and `thumbnail`, - see [file uploads](#file-uploads) for details. + post to use default thumbnail. If `anonymous` is set to truthy value, the + uploader name won't be recorded (privilege verification still applies; it's + 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 - **Request** diff --git a/server/szurubooru/api/context.py b/server/szurubooru/api/context.py index 93db39f..e66873c 100644 --- a/server/szurubooru/api/context.py +++ b/server/szurubooru/api/context.py @@ -2,6 +2,26 @@ import falcon from szurubooru import errors 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): def __init__(self): self.session = None @@ -27,57 +47,45 @@ class Context(object): raise errors.MissingRequiredFileError( 'Required file %r is missing.' % name) - def get_param_as_list(self, name, required=False, default=None): - if name in self.input: - param = self.input[name] - if not isinstance(param, list): - return [param] - return param - if not required: - return default - raise errors.MissingRequiredParameterError( - 'Required paramter %r is missing.' % name) + @_param_wrapper + def get_param_as_list(self, value): + if not isinstance(value, list): + return [value] + return value - def get_param_as_string(self, name, required=False, default=None): - if name in self.input: - param = self.input[name] - if isinstance(param, list): - try: - param = ','.join(param) - except: - raise errors.InvalidParameterError( - 'Parameter %r is invalid - expected simple string.' - % name) - return param - if not required: - return default - raise errors.MissingRequiredParameterError( - 'Required paramter %r is missing.' % name) - - # pylint: disable=redefined-builtin,too-many-arguments - def get_param_as_int( - self, name, required=False, min=None, max=None, default=None): - if name in self.input: - val = self.input[name] + @_param_wrapper + def get_param_as_string(self, value): + if isinstance(value, list): try: - val = int(val) - except (ValueError, TypeError): - raise errors.InvalidParameterError( - 'Parameter %r is invalid: the value must be an integer.' - % name) - if min is not None and val < min: - raise errors.InvalidParameterError( - 'Parameter %r is invalid: the value must be at least %r.' - % (name, min)) - if max is not None and val > max: - raise errors.InvalidParameterError( - 'Parameter %r is invalid: the value may not exceed %r.' - % (name, max)) - return val - if not required: - return default - raise errors.MissingRequiredParameterError( - 'Required parameter %r is missing.' % name) + value = ','.join(value) + except: + raise errors.InvalidParameterError('Expected simple string.') + return value + + # pylint: disable=redefined-builtin + @_param_wrapper + def get_param_as_int(self, value, min=None, max=None): + try: + value = int(value) + except (ValueError, TypeError): + raise errors.InvalidParameterError( + 'The value must be an integer.') + if min is not None and value < min: + raise errors.InvalidParameterError( + 'The value must be at least %r.' % min) + if max is not None and value > max: + raise errors.InvalidParameterError( + 'The value may not exceed %r.' % max) + return value + + @_param_wrapper + def get_param_as_bool(self, value): + value = str(value).lower() + 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): context_type = Context diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py index c6a2713..1fc38a2 100644 --- a/server/szurubooru/api/post_api.py +++ b/server/szurubooru/api/post_api.py @@ -22,7 +22,11 @@ class PostListApi(BaseApi): ctx, lambda post: _serialize_post(ctx, post)) 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) tag_names = ctx.get_param_as_list('tags', 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 [] 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_source(post, source) posts.update_post_relations(post, relations) diff --git a/server/szurubooru/tests/api/test_context.py b/server/szurubooru/tests/api/test_context.py index edccd90..9f74c05 100644 --- a/server/szurubooru/tests/api/test_context.py +++ b/server/szurubooru/tests/api/test_context.py @@ -62,3 +62,40 @@ def test_getting_int_parameter(): with pytest.raises(errors.ValidationError): assert ctx.get_param_as_int('key', max=50) == 50 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 diff --git a/server/szurubooru/tests/api/test_post_creating.py b/server/szurubooru/tests/api/test_post_creating.py index a15efbc..ae207d3 100644 --- a/server/szurubooru/tests/api/test_post_creating.py +++ b/server/szurubooru/tests/api/test_post_creating.py @@ -8,7 +8,10 @@ from szurubooru.func import posts, tags, snapshots, net @pytest.fixture(autouse=True) def inject_config(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( @@ -19,16 +22,15 @@ def test_creating_minimal_posts( db.session.flush() with unittest.mock.patch('szurubooru.func.posts.create_post'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_safety'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_source'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_relations'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_notes'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_flags'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_thumbnail'), \ - unittest.mock.patch('szurubooru.func.posts.serialize_post'), \ - unittest.mock.patch('szurubooru.func.tags.export_to_json'), \ - unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'): - + unittest.mock.patch('szurubooru.func.posts.update_post_safety'), \ + unittest.mock.patch('szurubooru.func.posts.update_post_source'), \ + unittest.mock.patch('szurubooru.func.posts.update_post_relations'), \ + unittest.mock.patch('szurubooru.func.posts.update_post_notes'), \ + unittest.mock.patch('szurubooru.func.posts.update_post_flags'), \ + unittest.mock.patch('szurubooru.func.posts.update_post_thumbnail'), \ + unittest.mock.patch('szurubooru.func.posts.serialize_post'), \ + unittest.mock.patch('szurubooru.func.tags.export_to_json'), \ + unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'): posts.create_post.return_value = post posts.serialize_post.return_value = 'serialized post' @@ -65,15 +67,14 @@ def test_creating_full_posts(context_factory, post_factory, user_factory): db.session.flush() with unittest.mock.patch('szurubooru.func.posts.create_post'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_safety'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_source'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_relations'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_notes'), \ - unittest.mock.patch('szurubooru.func.posts.update_post_flags'), \ - unittest.mock.patch('szurubooru.func.posts.serialize_post'), \ - unittest.mock.patch('szurubooru.func.tags.export_to_json'), \ - unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'): - + unittest.mock.patch('szurubooru.func.posts.update_post_safety'), \ + unittest.mock.patch('szurubooru.func.posts.update_post_source'), \ + unittest.mock.patch('szurubooru.func.posts.update_post_relations'), \ + unittest.mock.patch('szurubooru.func.posts.update_post_notes'), \ + unittest.mock.patch('szurubooru.func.posts.update_post_flags'), \ + unittest.mock.patch('szurubooru.func.posts.serialize_post'), \ + unittest.mock.patch('szurubooru.func.tags.export_to_json'), \ + unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'): posts.create_post.return_value = 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() 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( config_injector, context_factory, post_factory, user_factory): auth_user = user_factory(rank=db.User.RANK_REGULAR) @@ -112,13 +143,13 @@ def test_creating_from_url_saves_source( db.session.flush() with unittest.mock.patch('szurubooru.func.net.download'), \ - 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'): + 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': db.User.RANK_REGULAR}, + 'privileges': {'posts:create:identified': db.User.RANK_REGULAR}, }) net.download.return_value = b'content' posts.create_post.return_value = post @@ -143,13 +174,13 @@ def test_creating_from_url_with_source_specified( db.session.flush() with unittest.mock.patch('szurubooru.func.net.download'), \ - 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'): + 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': db.User.RANK_REGULAR}, + 'privileges': {'posts:create:identified': db.User.RANK_REGULAR}, }) net.download.return_value = b'content' posts.create_post.return_value = post