Merge branch 'master' into github-master

# Conflicts:
#	server/szurubooru/api/user_api.py
This commit is contained in:
ReAnzu 2018-02-24 01:59:59 -06:00
commit 2383e75aa5
10 changed files with 175 additions and 32 deletions

View file

@ -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');

View file

@ -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(() => {

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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):