Merge branch 'master' into github-master
# Conflicts: # server/szurubooru/api/user_api.py
This commit is contained in:
commit
2383e75aa5
10 changed files with 175 additions and 32 deletions
|
@ -47,10 +47,12 @@ class TopNavigationController {
|
||||||
topNavigation.hide('users');
|
topNavigation.hide('users');
|
||||||
}
|
}
|
||||||
if (api.isLoggedIn()) {
|
if (api.isLoggedIn()) {
|
||||||
|
if (!api.hasPrivilege('users:create:any')) {
|
||||||
topNavigation.hide('register');
|
topNavigation.hide('register');
|
||||||
|
}
|
||||||
topNavigation.hide('login');
|
topNavigation.hide('login');
|
||||||
} else {
|
} else {
|
||||||
if (!api.hasPrivilege('users:create')) {
|
if (!api.hasPrivilege('users:create:self')) {
|
||||||
topNavigation.hide('register');
|
topNavigation.hide('register');
|
||||||
}
|
}
|
||||||
topNavigation.hide('account');
|
topNavigation.hide('account');
|
||||||
|
|
|
@ -10,7 +10,7 @@ const EmptyView = require('../views/empty_view.js');
|
||||||
|
|
||||||
class UserRegistrationController {
|
class UserRegistrationController {
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!api.hasPrivilege('users:create')) {
|
if (!api.hasPrivilege('users:create:self')) {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError('Registration is closed.');
|
this._view.showError('Registration is closed.');
|
||||||
return;
|
return;
|
||||||
|
@ -30,6 +30,7 @@ class UserRegistrationController {
|
||||||
user.email = e.detail.email;
|
user.email = e.detail.email;
|
||||||
user.password = e.detail.password;
|
user.password = e.detail.password;
|
||||||
user.save().then(() => {
|
user.save().then(() => {
|
||||||
|
// TODO: Support the flow where an admin creates a user. Don't log them out...
|
||||||
api.forget();
|
api.forget();
|
||||||
return api.login(e.detail.name, e.detail.password, false);
|
return api.login(e.detail.name, e.detail.password, false);
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
|
|
|
@ -27,6 +27,12 @@ thumbnails:
|
||||||
post_height: 300
|
post_height: 300
|
||||||
|
|
||||||
|
|
||||||
|
convert:
|
||||||
|
gif:
|
||||||
|
generate_webm: false
|
||||||
|
generate_mp4: false
|
||||||
|
|
||||||
|
|
||||||
# used to send password reset e-mails
|
# used to send password reset e-mails
|
||||||
smtp:
|
smtp:
|
||||||
host: # example: localhost
|
host: # example: localhost
|
||||||
|
|
|
@ -69,6 +69,7 @@ def create_post(
|
||||||
posts.update_post_thumbnail(post, ctx.get_file('thumbnail'))
|
posts.update_post_thumbnail(post, ctx.get_file('thumbnail'))
|
||||||
ctx.session.add(post)
|
ctx.session.add(post)
|
||||||
ctx.session.flush()
|
ctx.session.flush()
|
||||||
|
posts.generate_alternate_formats(post, content)
|
||||||
snapshots.create(post, None if anonymous else ctx.user)
|
snapshots.create(post, None if anonymous else ctx.user)
|
||||||
for tag in new_tags:
|
for tag in new_tags:
|
||||||
snapshots.create(tag, None if anonymous else ctx.user)
|
snapshots.create(tag, None if anonymous else ctx.user)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
from szurubooru import model, search, rest
|
from szurubooru import model, search, rest, config, errors
|
||||||
from szurubooru.func import auth, users, serialization, versions
|
from szurubooru.func import auth, users, serialization, versions
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,7 +26,11 @@ def get_users(
|
||||||
@rest.routes.post('/users/?')
|
@rest.routes.post('/users/?')
|
||||||
def create_user(
|
def create_user(
|
||||||
ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
|
ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
|
||||||
auth.verify_privilege(ctx.user, 'users:create')
|
if ctx.user.user_id is None:
|
||||||
|
auth.verify_privilege(ctx.user, 'users:create:self')
|
||||||
|
else:
|
||||||
|
auth.verify_privilege(ctx.user, 'users:create:any')
|
||||||
|
|
||||||
name = ctx.get_param_as_string('name')
|
name = ctx.get_param_as_string('name')
|
||||||
password = ctx.get_param_as_string('password')
|
password = ctx.get_param_as_string('password')
|
||||||
email = ctx.get_param_as_string('email', default='')
|
email = ctx.get_param_as_string('email', default='')
|
||||||
|
@ -40,6 +44,10 @@ def create_user(
|
||||||
ctx.get_file('avatar', default=b''))
|
ctx.get_file('avatar', default=b''))
|
||||||
ctx.session.add(user)
|
ctx.session.add(user)
|
||||||
ctx.session.commit()
|
ctx.session.commit()
|
||||||
|
|
||||||
|
if ctx.user.user_id is not None:
|
||||||
|
user = ctx.user
|
||||||
|
|
||||||
return _serialize(ctx, user, force_show_email=True)
|
return _serialize(ctx, user, force_show_email=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -33,10 +33,14 @@ class Image:
|
||||||
return self.info['streams'][0]['nb_read_frames']
|
return self.info['streams'][0]['nb_read_frames']
|
||||||
|
|
||||||
def resize_fill(self, width: int, height: int) -> None:
|
def resize_fill(self, width: int, height: int) -> None:
|
||||||
|
|
||||||
|
width_greater = self.width > self.height
|
||||||
|
scale_param = "scale='%d:%d" % ((-1, height) if width_greater else (width, -1))
|
||||||
|
|
||||||
cli = [
|
cli = [
|
||||||
'-i', '{path}',
|
'-i', '{path}',
|
||||||
'-f', 'image2',
|
'-f', 'image2',
|
||||||
'-vf', _SCALE_FIT_FMT.format(width=width, height=height),
|
'-filter:v', scale_param,
|
||||||
'-map', '0:v:0',
|
'-map', '0:v:0',
|
||||||
'-vframes', '1',
|
'-vframes', '1',
|
||||||
'-vcodec', 'png',
|
'-vcodec', 'png',
|
||||||
|
@ -50,7 +54,7 @@ class Image:
|
||||||
'-ss',
|
'-ss',
|
||||||
'%d' % math.floor(duration * 0.3),
|
'%d' % math.floor(duration * 0.3),
|
||||||
] + cli
|
] + cli
|
||||||
content = self._execute(cli)
|
content = self._execute(cli, ignore_error_if_data=True)
|
||||||
if not content:
|
if not content:
|
||||||
raise errors.ProcessingError('Error while resizing image.')
|
raise errors.ProcessingError('Error while resizing image.')
|
||||||
self.content = content
|
self.content = content
|
||||||
|
@ -79,7 +83,73 @@ class Image:
|
||||||
'-',
|
'-',
|
||||||
])
|
])
|
||||||
|
|
||||||
def _execute(self, cli: List[str], program: str = 'ffmpeg') -> bytes:
|
def to_webm(self) -> bytes:
|
||||||
|
with util.create_temp_file_path(suffix='.log') as phase_log_path:
|
||||||
|
# Pass 1
|
||||||
|
self._execute([
|
||||||
|
'-i', '{path}',
|
||||||
|
'-pass', '1',
|
||||||
|
'-passlogfile', phase_log_path,
|
||||||
|
'-vcodec', 'libvpx-vp9',
|
||||||
|
'-crf', '4',
|
||||||
|
'-b:v', '2500K',
|
||||||
|
'-acodec', 'libvorbis',
|
||||||
|
'-f', 'webm',
|
||||||
|
'-y', '/dev/null'
|
||||||
|
])
|
||||||
|
|
||||||
|
# Pass 2
|
||||||
|
return self._execute([
|
||||||
|
'-i', '{path}',
|
||||||
|
'-pass', '2',
|
||||||
|
'-passlogfile', phase_log_path,
|
||||||
|
'-vcodec', 'libvpx-vp9',
|
||||||
|
'-crf', '4',
|
||||||
|
'-b:v', '2500K',
|
||||||
|
'-acodec', 'libvorbis',
|
||||||
|
'-f', 'webm',
|
||||||
|
'-'
|
||||||
|
])
|
||||||
|
|
||||||
|
def to_mp4(self) -> bytes:
|
||||||
|
|
||||||
|
with util.create_temp_file_path(suffix='.dat') as mp4_temp_path:
|
||||||
|
|
||||||
|
width = self.width
|
||||||
|
height = self.height
|
||||||
|
altered_dimensions = False
|
||||||
|
|
||||||
|
if self.width % 2 != 0:
|
||||||
|
width = self.width - 1
|
||||||
|
altered_dimensions = True
|
||||||
|
|
||||||
|
if self.height % 2 != 0:
|
||||||
|
height = self.height - 1
|
||||||
|
altered_dimensions = True
|
||||||
|
|
||||||
|
args = [
|
||||||
|
'-i', '{path}',
|
||||||
|
'-vcodec', 'libx264',
|
||||||
|
'-preset', 'slow',
|
||||||
|
'-crf', '22',
|
||||||
|
'-b:v', '200K',
|
||||||
|
'-profile:v', 'main',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-acodec', 'aac',
|
||||||
|
'-f', 'mp4'
|
||||||
|
]
|
||||||
|
|
||||||
|
if altered_dimensions:
|
||||||
|
args = args + [
|
||||||
|
'-filter:v', 'scale=\'%d:%d\'' % (width, height)
|
||||||
|
]
|
||||||
|
|
||||||
|
self._execute(args + ['-y', mp4_temp_path])
|
||||||
|
|
||||||
|
with open(mp4_temp_path, 'rb') as mp4_temp:
|
||||||
|
return mp4_temp.read()
|
||||||
|
|
||||||
|
def _execute(self, cli: List[str], program: str = 'ffmpeg', ignore_error_if_data: bool = False) -> bytes:
|
||||||
extension = mime.get_extension(mime.get_mime_type(self.content))
|
extension = mime.get_extension(mime.get_mime_type(self.content))
|
||||||
assert extension
|
assert extension
|
||||||
with util.create_temp_file(suffix='.' + extension) as handle:
|
with util.create_temp_file(suffix='.' + extension) as handle:
|
||||||
|
@ -98,6 +168,7 @@ class Image:
|
||||||
'Failed to execute ffmpeg command (cli=%r, err=%r)',
|
'Failed to execute ffmpeg command (cli=%r, err=%r)',
|
||||||
' '.join(shlex.quote(arg) for arg in cli),
|
' '.join(shlex.quote(arg) for arg in cli),
|
||||||
err)
|
err)
|
||||||
|
if (not ignore_error_if_data and len(out) > 0) or len(out) == 0:
|
||||||
raise errors.ProcessingError(
|
raise errors.ProcessingError(
|
||||||
'Error while processing image.\n' + err.decode('utf-8'))
|
'Error while processing image.\n' + err.decode('utf-8'))
|
||||||
return out
|
return out
|
||||||
|
|
|
@ -1,58 +1,66 @@
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
APPLICATION_SWF = 'application/x-shockwave-flash'
|
||||||
|
IMAGE_JPEG = 'image/jpeg'
|
||||||
|
IMAGE_PNG = 'image/png'
|
||||||
|
IMAGE_GIF = 'image/gif'
|
||||||
|
VIDEO_WEBM = 'video/webm'
|
||||||
|
VIDEO_MP4 = 'video/mp4'
|
||||||
|
APPLICATION_OCTET_STREAM = 'application/octet-stream'
|
||||||
|
|
||||||
|
|
||||||
def get_mime_type(content: bytes) -> str:
|
def get_mime_type(content: bytes) -> str:
|
||||||
if not content:
|
if not content:
|
||||||
return 'application/octet-stream'
|
return APPLICATION_OCTET_STREAM
|
||||||
|
|
||||||
if content[0:3] in (b'CWS', b'FWS', b'ZWS'):
|
if content[0:3] in (b'CWS', b'FWS', b'ZWS'):
|
||||||
return 'application/x-shockwave-flash'
|
return APPLICATION_SWF
|
||||||
|
|
||||||
if content[0:3] == b'\xFF\xD8\xFF':
|
if content[0:3] == b'\xFF\xD8\xFF':
|
||||||
return 'image/jpeg'
|
return IMAGE_JPEG
|
||||||
|
|
||||||
if content[0:6] == b'\x89PNG\x0D\x0A':
|
if content[0:6] == b'\x89PNG\x0D\x0A':
|
||||||
return 'image/png'
|
return IMAGE_PNG
|
||||||
|
|
||||||
if content[0:6] in (b'GIF87a', b'GIF89a'):
|
if content[0:6] in (b'GIF87a', b'GIF89a'):
|
||||||
return 'image/gif'
|
return IMAGE_GIF
|
||||||
|
|
||||||
if content[0:4] == b'\x1A\x45\xDF\xA3':
|
if content[0:4] == b'\x1A\x45\xDF\xA3':
|
||||||
return 'video/webm'
|
return VIDEO_WEBM
|
||||||
|
|
||||||
if content[4:12] in (b'ftypisom', b'ftypmp42'):
|
if content[4:12] in (b'ftypisom', b'ftypmp42'):
|
||||||
return 'video/mp4'
|
return VIDEO_MP4
|
||||||
|
|
||||||
return 'application/octet-stream'
|
return APPLICATION_OCTET_STREAM
|
||||||
|
|
||||||
|
|
||||||
def get_extension(mime_type: str) -> Optional[str]:
|
def get_extension(mime_type: str) -> Optional[str]:
|
||||||
extension_map = {
|
extension_map = {
|
||||||
'application/x-shockwave-flash': 'swf',
|
APPLICATION_SWF: 'swf',
|
||||||
'image/gif': 'gif',
|
IMAGE_GIF: 'gif',
|
||||||
'image/jpeg': 'jpg',
|
IMAGE_JPEG: 'jpg',
|
||||||
'image/png': 'png',
|
IMAGE_PNG: 'png',
|
||||||
'video/mp4': 'mp4',
|
VIDEO_MP4: 'mp4',
|
||||||
'video/webm': 'webm',
|
VIDEO_WEBM: 'webm',
|
||||||
'application/octet-stream': 'dat',
|
APPLICATION_OCTET_STREAM: 'dat',
|
||||||
}
|
}
|
||||||
return extension_map.get((mime_type or '').strip().lower(), None)
|
return extension_map.get((mime_type or '').strip().lower(), None)
|
||||||
|
|
||||||
|
|
||||||
def is_flash(mime_type: str) -> bool:
|
def is_flash(mime_type: str) -> bool:
|
||||||
return mime_type.lower() == 'application/x-shockwave-flash'
|
return mime_type.lower() == APPLICATION_SWF
|
||||||
|
|
||||||
|
|
||||||
def is_video(mime_type: str) -> bool:
|
def is_video(mime_type: str) -> bool:
|
||||||
return mime_type.lower() in ('application/ogg', 'video/mp4', 'video/webm')
|
return mime_type.lower() in ('application/ogg', VIDEO_MP4, VIDEO_WEBM)
|
||||||
|
|
||||||
|
|
||||||
def is_image(mime_type: str) -> bool:
|
def is_image(mime_type: str) -> bool:
|
||||||
return mime_type.lower() in ('image/jpeg', 'image/png', 'image/gif')
|
return mime_type.lower() in (IMAGE_JPEG, IMAGE_PNG, IMAGE_GIF)
|
||||||
|
|
||||||
|
|
||||||
def is_animated_gif(content: bytes) -> bool:
|
def is_animated_gif(content: bytes) -> bool:
|
||||||
pattern = b'\x21\xF9\x04[\x00-\xFF]{4}\x00[\x2C\x21]'
|
pattern = b'\x21\xF9\x04[\x00-\xFF]{4}\x00[\x2C\x21]'
|
||||||
return get_mime_type(content) == 'image/gif' \
|
return get_mime_type(content) == IMAGE_GIF \
|
||||||
and len(re.findall(pattern, content)) > 1
|
and len(re.findall(pattern, content)) > 1
|
||||||
|
|
|
@ -5,7 +5,7 @@ import sqlalchemy as sa
|
||||||
from szurubooru import config, db, model, errors, rest
|
from szurubooru import config, db, model, errors, rest
|
||||||
from szurubooru.func import (
|
from szurubooru.func import (
|
||||||
users, scores, comments, tags, util,
|
users, scores, comments, tags, util,
|
||||||
mime, images, files, image_hash, serialization)
|
mime, images, files, image_hash, serialization, snapshots)
|
||||||
|
|
||||||
|
|
||||||
EMPTY_PIXEL = (
|
EMPTY_PIXEL = (
|
||||||
|
@ -399,7 +399,9 @@ def _after_post_update(
|
||||||
def _before_post_delete(
|
def _before_post_delete(
|
||||||
_mapper: Any, _connection: Any, post: model.Post) -> None:
|
_mapper: Any, _connection: Any, post: model.Post) -> None:
|
||||||
if post.post_id:
|
if post.post_id:
|
||||||
image_hash.delete_image(post.post_id)
|
image_hash.delete_image(str(post.post_id))
|
||||||
|
files.delete(get_post_content_path(post))
|
||||||
|
files.delete(get_post_thumbnail_path(post))
|
||||||
|
|
||||||
|
|
||||||
def _sync_post_content(post: model.Post) -> None:
|
def _sync_post_content(post: model.Post) -> None:
|
||||||
|
@ -429,6 +431,40 @@ def _sync_post_content(post: model.Post) -> None:
|
||||||
generate_post_thumbnail(post)
|
generate_post_thumbnail(post)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_alternate_formats(post: model.Post, content: bytes) -> None:
|
||||||
|
assert post
|
||||||
|
assert content
|
||||||
|
if mime.is_animated_gif(content):
|
||||||
|
tag_names = [tag_name.name for tag_name in [tag.names for tag in post.tags]]
|
||||||
|
new_posts = []
|
||||||
|
|
||||||
|
if config.config['convert']['gif']['generate_mp4']:
|
||||||
|
mp4_post, new_tags = create_post(images.Image(content).to_mp4(), tag_names, post.user)
|
||||||
|
update_post_flags(mp4_post, ['loop'])
|
||||||
|
update_post_safety(mp4_post, post.safety)
|
||||||
|
update_post_source(mp4_post, post.source)
|
||||||
|
new_posts += [(mp4_post, new_tags)]
|
||||||
|
|
||||||
|
if config.config['convert']['gif']['generate_webm']:
|
||||||
|
webm_post, new_tags = create_post(images.Image(content).to_webm(), tag_names, post.user)
|
||||||
|
update_post_flags(webm_post, ['loop'])
|
||||||
|
update_post_safety(webm_post, post.safety)
|
||||||
|
update_post_source(webm_post, post.source)
|
||||||
|
new_posts += [(webm_post, new_tags)]
|
||||||
|
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
new_posts = list(filter(lambda i: i[0] is not None, new_posts))
|
||||||
|
|
||||||
|
for new_post, new_tags in new_posts:
|
||||||
|
snapshots.create(new_post, post.user)
|
||||||
|
for tag in new_tags:
|
||||||
|
snapshots.create(tag, post.user)
|
||||||
|
|
||||||
|
new_relations = [p[0].post_id for p in new_posts]
|
||||||
|
update_post_relations(post, new_relations) if len(new_relations) > 0 else None
|
||||||
|
|
||||||
|
|
||||||
def update_post_content(post: model.Post, content: Optional[bytes]) -> None:
|
def update_post_content(post: model.Post, content: Optional[bytes]) -> None:
|
||||||
assert post
|
assert post
|
||||||
if not content:
|
if not content:
|
||||||
|
|
|
@ -41,6 +41,16 @@ def create_temp_file(**kwargs: Any) -> Generator:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def create_temp_file_path(**kwargs: Any) -> Generator:
|
||||||
|
(descriptor, path) = tempfile.mkstemp(**kwargs)
|
||||||
|
os.close(descriptor)
|
||||||
|
try:
|
||||||
|
yield path
|
||||||
|
finally:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
def unalias_dict(source: List[Tuple[List[str], T]]) -> Dict[str, T]:
|
def unalias_dict(source: List[Tuple[List[str], T]]) -> Dict[str, T]:
|
||||||
output_dict = {} # type: Dict[str, T]
|
output_dict = {} # type: Dict[str, T]
|
||||||
for aliases, value in source:
|
for aliases, value in source:
|
||||||
|
|
|
@ -6,7 +6,7 @@ from szurubooru.func import users
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def inject_config(config_injector):
|
def inject_config(config_injector):
|
||||||
config_injector({'privileges': {'users:create': 'regular'}})
|
config_injector({'privileges': {'users:create:self': 'regular'}})
|
||||||
|
|
||||||
|
|
||||||
def test_creating_user(user_factory, context_factory, fake_datetime):
|
def test_creating_user(user_factory, context_factory, fake_datetime):
|
||||||
|
|
Loading…
Reference in a new issue