server/posts: allow anonymous uploads (#90)
This commit is contained in:
parent
a20bf56e75
commit
508cb6e7ab
5 changed files with 172 additions and 87 deletions
12
API.md
12
API.md
|
@ -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**
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue