diff --git a/client/js/controllers/top_navigation_controller.js b/client/js/controllers/top_navigation_controller.js index b1de03ec..550400cf 100644 --- a/client/js/controllers/top_navigation_controller.js +++ b/client/js/controllers/top_navigation_controller.js @@ -47,10 +47,12 @@ class TopNavigationController { topNavigation.hide('users'); } if (api.isLoggedIn()) { - topNavigation.hide('register'); + if (!api.hasPrivilege('users:create:any')) { + topNavigation.hide('register'); + } topNavigation.hide('login'); } else { - if (!api.hasPrivilege('users:create')) { + if (!api.hasPrivilege('users:create:self')) { topNavigation.hide('register'); } topNavigation.hide('account'); diff --git a/client/js/controllers/user_registration_controller.js b/client/js/controllers/user_registration_controller.js index 7d822380..cc224ced 100644 --- a/client/js/controllers/user_registration_controller.js +++ b/client/js/controllers/user_registration_controller.js @@ -10,7 +10,7 @@ const EmptyView = require('../views/empty_view.js'); class UserRegistrationController { constructor() { - if (!api.hasPrivilege('users:create')) { + if (!api.hasPrivilege('users:create:self')) { this._view = new EmptyView(); this._view.showError('Registration is closed.'); return; @@ -30,6 +30,7 @@ class UserRegistrationController { user.email = e.detail.email; user.password = e.detail.password; user.save().then(() => { + // TODO: Support the flow where an admin creates a user. Don't log them out... api.forget(); return api.login(e.detail.name, e.detail.password, false); }).then(() => { diff --git a/config.yaml.dist b/config.yaml.dist index 42267452..0293d2af 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -27,6 +27,12 @@ thumbnails: post_height: 300 +convert: + gif: + generate_webm: false + generate_mp4: false + + # used to send password reset e-mails smtp: host: # example: localhost diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py index 27f10c16..1ae62af7 100644 --- a/server/szurubooru/api/post_api.py +++ b/server/szurubooru/api/post_api.py @@ -69,6 +69,7 @@ def create_post( posts.update_post_thumbnail(post, ctx.get_file('thumbnail')) ctx.session.add(post) ctx.session.flush() + posts.generate_alternate_formats(post, content) snapshots.create(post, None if anonymous else ctx.user) for tag in new_tags: snapshots.create(tag, None if anonymous else ctx.user) diff --git a/server/szurubooru/api/user_api.py b/server/szurubooru/api/user_api.py index e456f22e..95d8e4fe 100644 --- a/server/szurubooru/api/user_api.py +++ b/server/szurubooru/api/user_api.py @@ -1,5 +1,5 @@ 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 @@ -26,7 +26,11 @@ def get_users( @rest.routes.post('/users/?') def create_user( 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') password = ctx.get_param_as_string('password') email = ctx.get_param_as_string('email', default='') @@ -40,6 +44,10 @@ def create_user( ctx.get_file('avatar', default=b'')) ctx.session.add(user) ctx.session.commit() + + if ctx.user.user_id is not None: + user = ctx.user + return _serialize(ctx, user, force_show_email=True) diff --git a/server/szurubooru/func/images.py b/server/szurubooru/func/images.py index 16587ed5..0bf84ed5 100644 --- a/server/szurubooru/func/images.py +++ b/server/szurubooru/func/images.py @@ -33,10 +33,14 @@ class Image: return self.info['streams'][0]['nb_read_frames'] 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 = [ '-i', '{path}', '-f', 'image2', - '-vf', _SCALE_FIT_FMT.format(width=width, height=height), + '-filter:v', scale_param, '-map', '0:v:0', '-vframes', '1', '-vcodec', 'png', @@ -50,7 +54,7 @@ class Image: '-ss', '%d' % math.floor(duration * 0.3), ] + cli - content = self._execute(cli) + content = self._execute(cli, ignore_error_if_data=True) if not content: raise errors.ProcessingError('Error while resizing image.') 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)) assert extension with util.create_temp_file(suffix='.' + extension) as handle: @@ -98,8 +168,9 @@ class Image: 'Failed to execute ffmpeg command (cli=%r, err=%r)', ' '.join(shlex.quote(arg) for arg in cli), err) - raise errors.ProcessingError( - 'Error while processing image.\n' + err.decode('utf-8')) + if (not ignore_error_if_data and len(out) > 0) or len(out) == 0: + raise errors.ProcessingError( + 'Error while processing image.\n' + err.decode('utf-8')) return out def _reload_info(self) -> None: diff --git a/server/szurubooru/func/mime.py b/server/szurubooru/func/mime.py index c83f744e..12e358c0 100644 --- a/server/szurubooru/func/mime.py +++ b/server/szurubooru/func/mime.py @@ -1,58 +1,66 @@ import re 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: if not content: - return 'application/octet-stream' + return APPLICATION_OCTET_STREAM 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': - return 'image/jpeg' + return IMAGE_JPEG if content[0:6] == b'\x89PNG\x0D\x0A': - return 'image/png' + return IMAGE_PNG if content[0:6] in (b'GIF87a', b'GIF89a'): - return 'image/gif' + return IMAGE_GIF if content[0:4] == b'\x1A\x45\xDF\xA3': - return 'video/webm' + return VIDEO_WEBM 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]: extension_map = { - 'application/x-shockwave-flash': 'swf', - 'image/gif': 'gif', - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'video/mp4': 'mp4', - 'video/webm': 'webm', - 'application/octet-stream': 'dat', + APPLICATION_SWF: 'swf', + IMAGE_GIF: 'gif', + IMAGE_JPEG: 'jpg', + IMAGE_PNG: 'png', + VIDEO_MP4: 'mp4', + VIDEO_WEBM: 'webm', + APPLICATION_OCTET_STREAM: 'dat', } return extension_map.get((mime_type or '').strip().lower(), None) 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: - 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: - 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: 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 diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index 406f6e18..4e524387 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -5,7 +5,7 @@ import sqlalchemy as sa from szurubooru import config, db, model, errors, rest from szurubooru.func import ( users, scores, comments, tags, util, - mime, images, files, image_hash, serialization) + mime, images, files, image_hash, serialization, snapshots) EMPTY_PIXEL = ( @@ -399,7 +399,9 @@ def _after_post_update( def _before_post_delete( _mapper: Any, _connection: Any, post: model.Post) -> None: 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: @@ -429,6 +431,40 @@ def _sync_post_content(post: model.Post) -> None: 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: assert post if not content: diff --git a/server/szurubooru/func/util.py b/server/szurubooru/func/util.py index 61e975b6..ba2d4dc9 100644 --- a/server/szurubooru/func/util.py +++ b/server/szurubooru/func/util.py @@ -41,6 +41,16 @@ def create_temp_file(**kwargs: Any) -> Generator: 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]: output_dict = {} # type: Dict[str, T] for aliases, value in source: diff --git a/server/szurubooru/tests/api/test_user_creating.py b/server/szurubooru/tests/api/test_user_creating.py index b5f36e39..699bfefb 100644 --- a/server/szurubooru/tests/api/test_user_creating.py +++ b/server/szurubooru/tests/api/test_user_creating.py @@ -6,7 +6,7 @@ from szurubooru.func import users @pytest.fixture(autouse=True) 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):