From ce095816d99acdcb642713719c775d463d238a91 Mon Sep 17 00:00:00 2001 From: rr- Date: Sat, 30 Apr 2016 23:17:08 +0200 Subject: [PATCH] server/posts: add post creating --- API.md | 127 +++-- server/szurubooru/api/__init__.py | 1 + server/szurubooru/api/context.py | 31 +- server/szurubooru/api/post_api.py | 23 + server/szurubooru/app.py | 1 + server/szurubooru/db/post.py | 20 +- server/szurubooru/errors.py | 4 + server/szurubooru/func/files.py | 17 +- server/szurubooru/func/images.py | 31 +- server/szurubooru/func/mime.py | 4 +- server/szurubooru/func/posts.py | 203 +++++++- server/szurubooru/func/tag_categories.py | 2 +- server/szurubooru/func/tags.py | 24 +- server/szurubooru/func/users.py | 7 +- server/szurubooru/func/util.py | 18 +- .../23abaf4a0a4b_add_mime_type_to_posts.py | 20 + .../tests/api/test_comment_creating.py | 1 + .../tests/api/test_comment_rating.py | 1 + .../tests/api/test_comment_retrieving.py | 1 + .../tests/api/test_comment_updating.py | 1 + .../tests/api/test_post_creating.py | 133 +++++ .../tests/api/test_post_favoriting.py | 1 + .../tests/api/test_post_featuring.py | 1 + .../szurubooru/tests/api/test_post_rating.py | 1 + .../tests/api/test_post_retrieving.py | 1 + server/szurubooru/tests/assets/flash.swf | Bin 140 -> 4790 bytes server/szurubooru/tests/assets/gif.gif | Bin 14 -> 43 bytes server/szurubooru/tests/assets/jpeg.jpg | Bin 107 -> 12656 bytes server/szurubooru/tests/assets/png-broken.png | Bin 0 -> 100 bytes .../tests/assets/png-transparent.png | Bin 67 -> 0 bytes server/szurubooru/tests/assets/png.png | Bin 0 -> 15332 bytes server/szurubooru/tests/conftest.py | 10 + server/szurubooru/tests/db/test_post.py | 1 + server/szurubooru/tests/func/test_mime.py | 2 +- server/szurubooru/tests/func/test_posts.py | 463 ++++++++++++++++++ 35 files changed, 1060 insertions(+), 90 deletions(-) create mode 100644 server/szurubooru/migrations/versions/23abaf4a0a4b_add_mime_type_to_posts.py create mode 100644 server/szurubooru/tests/api/test_post_creating.py create mode 100644 server/szurubooru/tests/assets/png-broken.png delete mode 100644 server/szurubooru/tests/assets/png-transparent.png create mode 100644 server/szurubooru/tests/assets/png.png create mode 100644 server/szurubooru/tests/func/test_posts.py diff --git a/API.md b/API.md index c36bbf6..05df63d 100644 --- a/API.md +++ b/API.md @@ -29,7 +29,7 @@ - [Listing tag siblings](#listing-tag-siblings) - Posts - ~~Listing posts~~ - - ~~Creating post~~ + - [Creating post](#creating-post) - ~~Updating post~~ - [Getting post](#getting-post) - [Deleting post](#deleting-post) @@ -69,6 +69,7 @@ - [Detailed tag](#detailed-tag) - [Post](#post) - [Detailed post](#detailed-post) + - [Note](#note) - [Comment](#comment) - [Detailed comment](#detailed-comment) - [Snapshot](#snapshot) @@ -125,7 +126,6 @@ Depending on the deployment, the URLs might be relative to some base path such as `/api/`. Values denoted with diamond braces (``) signify variable data. - ## Listing tag categories - **Request** @@ -150,7 +150,6 @@ data. caching. The data directory and its URL are controlled with `data_dir` and `data_url` variables in server's configuration. - ## Creating tag category - **Request** @@ -181,7 +180,6 @@ data. Creates a new tag category using specified parameters. Name must match `tag_category_name_regex` from server's configuration. - ## Updating tag category - **Request** @@ -214,7 +212,6 @@ data. match `tag_category_name_regex` from server's configuration. All fields are optional - update concerns only provided fields. - ## Getting tag category - **Request** @@ -233,7 +230,6 @@ data. Retrieves information about an existing tag category. - ## Deleting tag category - **Request** @@ -257,7 +253,6 @@ data. Deletes existing tag category. The tag category to be deleted must have no usages. - ## Listing tags - **Request** @@ -327,7 +322,6 @@ data. None. - ## Creating tag - **Request** @@ -370,7 +364,6 @@ data. first tag category found. If there are no tag categories established yet, an error will be thrown. - ## Updating tag - **Request** @@ -412,7 +405,6 @@ data. their category is set to the first tag category found. All fields are optional - update concerns only provided fields. - ## Getting tag - **Request** @@ -431,7 +423,6 @@ data. Retrieves information about an existing tag. - ## Deleting tag - **Request** @@ -453,7 +444,6 @@ data. Deletes existing tag. The tag to be deleted must have no usages. - ## Merging tags - **Request** @@ -485,7 +475,6 @@ data. and are discarded. The target tag effectively remains unchanged with the exception of the set of posts it's used in. - ## Listing tag siblings - **Request** @@ -520,6 +509,48 @@ data. appears with given tag. Results are sorted by occurrences count and the list is truncated to the first 50 elements. Doesn't use paging. +## Creating post +- **Request** + + `POST /posts/` + +- **Input** + + ```json5 + { + "tags": [, , ], + "safety": , + "source": , // optional + "relations": [, , ], // optional + "notes": [, , ], // optional + "flags": [, ] // optional + } + ``` + +- **Files** + + - `content` - the content of the content. + - `thumbnail` - the content of custom thumbnail (optional). + +- **Output** + + A [detailed post resource](#detailed-post). + +- **Errors** + + - tags have invalid names + - safety is invalid + - relations refer to non-existing posts + - privileges are too low + +- **Description** + + Creates a new post. If specified tags do not exist yet, they will be + automatically created. Tags created automatically have no implications, no + suggestions, one name and their category is set to the first tag category + found. Safety must be any of `"safe"`, `"sketchy"` or `"unsafe"`. `` + currently can be only `"loop"` to enable looping for video posts. Sending + empty `thumbnail` will cause the post to use default thumbnail. ## Getting post - **Request** @@ -539,7 +570,6 @@ data. Retrieves information about an existing post. - ## Deleting post - **Request** @@ -560,7 +590,6 @@ data. Deletes existing post. Related posts and tags are kept. - ## Rating post - **Request** @@ -589,7 +618,6 @@ data. Updates score of authenticated user for given post. Valid scores are -1, 0 and 1. - ## Adding post to favorites - **Request** @@ -608,7 +636,6 @@ data. Marks the post as favorite for authenticated user. - ## Removing post from favorites - **Request** @@ -627,7 +654,6 @@ data. Unmarks the post as favorite for authenticated user. - ## Getting featured post - **Request** @@ -647,7 +673,6 @@ data. client. If no post is featured, `` is null and `snapshots` array is empty. - ## Featuring post - **Request** @@ -666,7 +691,6 @@ data. Features a post on the main page in web client. - ## Listing comments - **Request** @@ -722,7 +746,6 @@ data. None. - ## Creating comment - **Request** @@ -751,7 +774,6 @@ data. Creates a new comment under given post. - ## Updating comment - **Request** @@ -779,7 +801,6 @@ data. Updates an existing comment text. - ## Getting comment - **Request** @@ -798,7 +819,6 @@ data. Retrieves information about an existing comment. - ## Deleting comment - **Request** @@ -819,7 +839,6 @@ data. Deletes existing comment. - ## Rating comment - **Request** @@ -848,7 +867,6 @@ data. Updates score of authenticated user for given comment. Valid scores are -1, 0 and 1. - ## Listing users - **Request** @@ -900,7 +918,6 @@ data. None. - ## Creating user - **Request** @@ -947,7 +964,6 @@ data. administrator, whereas subsequent users will be given the rank indicated by `default_rank` in the server's configuration. - ## Updating user - **Request** @@ -993,7 +1009,6 @@ data. `manual`. `manual` avatar style requires client to pass also `avatar` file - see [file uploads](#file-uploads) for details. - ## Getting user - **Request** @@ -1012,7 +1027,6 @@ data. Retrieves information about an existing user. - ## Deleting user - **Request** @@ -1033,7 +1047,6 @@ data. Deletes existing user. - ## Password reset - step 1: mail request - **Request** @@ -1058,7 +1071,6 @@ data. mailbox, which is a strong indication they are the rightful owner of the account. - ## Password reset - step 2: confirmation - **Request** @@ -1091,7 +1103,6 @@ data. Generates a new password for given user. Password is sent as plain-text, so it is recommended to connect through HTTPS. - ## Listing snapshots - **Request** @@ -1133,7 +1144,6 @@ data. None. - ## Getting global info - **Request** @@ -1325,29 +1335,34 @@ One file together with its metadata posted to the site. ```json5 { "id": , + "creationTime": , + "lastEditTime": , "safety": , + "source": , "type": , "checksum": , - "source": , "canvasWidth": , "canvasHeight": , + "contentUrl": , + "thumbnailUrl": , "flags": , "tags": , "relations": , - "creationTime": , - "lastEditTime": , + "notes": , "user": , "score": , "ownScore": , - "favoritedBy": , "featureCount": , - "lastFeatureTime": + "lastFeatureTime": , + "favoritedBy": } ``` **Field meaning** - ``: the post identifier. +- ``: time the tag was created, formatted as per RFC 3339. +- ``: time the tag was edited, formatted as per RFC 3339. - ``: whether the post is safe for work. Available values: @@ -1356,6 +1371,7 @@ One file together with its metadata posted to the site. - `"sketchy"` - `"unsafe"` +- ``: where the post was grabbed form, supplied by the user. - ``: the type of the post. Available values: @@ -1368,24 +1384,25 @@ One file together with its metadata posted to the site. - ``: the file checksum. Used in snapshots to signify changes of the post content. -- ``: where the post was grabbed form, supplied by the user. - `` and ``: the original width and height of the post content. +- ``: where the post content is located. +- ``: where the post thumbnail is located. - ``: various flags such as whether the post is looped, represented as array of plain strings. - ``: list of tag names the post is tagged with. - ``: a list of related post IDs. Links to related posts are shown to the user by the web client. -- ``: time the tag was created, formatted as per RFC 3339. -- ``: time the tag was edited, formatted as per RFC 3339. +- ``: a list of post annotations, serialized as list of [note + resources](#note). - ``: who created the post, serialized as [user resource](#user). - ``: the collective score (+1/-1 rating) of the given post. - ``: the score (+1/-1 rating) of the given post by the authenticated user. -- ``: list of users, serialized as [user resources](#user). - ``: how many times has the post been featured. - ``: the last time the post was featured, formatted as per RFC 3339. +- ``: list of users, serialized as [user resources](#user). ## Detailed post **Description** @@ -1416,6 +1433,27 @@ A post with extra information. earlier versions. - ``: a [comment resource](#comment) for given post. +## Note +**Description** + +A text annotation rendered on top of the post. + +**Structure** + +```json5 +{ + "polygon": , + "text": , +} +``` + +**Field meaning** +- ``: where to draw the annotation. Each point must have + coordinates within 0 to 1. For example, `[[0,0],[0,1],[1,1],[1,0]]` will draw + the annotation on the whole post, whereas `[[0,0],[0,0.5],[0.5,0.5],[0.5,0]]` + will draw it inside the post's upper left quarter. +- ``: the annotation text. The client should render is as Markdown. + ## Comment **Description** @@ -1439,6 +1477,7 @@ A comment under a post. **Field meaning** - ``: the comment identifier. - ``: a post resource the post is linked with. +- ``: the comment content. The client should render is as Markdown. - ``: a user resource the post is created by. - ``: time the comment was created, formatted as per RFC 3339. - ``: time the comment was edited, formatted as per RFC 3339. @@ -1542,7 +1581,7 @@ A snapshot is a version of a database resource. "checksum": "deadbeef", "tags": ["tag1", "tag2"], "relations": [1, 2], - "notes": [{"polygon": [[1,1],[200,1],[200,200],[1,200]], "text": "..."}], + "notes": [, , ], "flags": ["loop"], "featured": false } diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index fb7324b..6649bf7 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -15,6 +15,7 @@ from szurubooru.api.comment_api import ( CommentDetailApi, CommentScoreApi) from szurubooru.api.post_api import ( + PostListApi, PostDetailApi, PostFeatureApi, PostScoreApi, diff --git a/server/szurubooru/api/context.py b/server/szurubooru/api/context.py index 58890e0..0829cf7 100644 --- a/server/szurubooru/api/context.py +++ b/server/szurubooru/api/context.py @@ -12,8 +12,16 @@ class Context(object): def has_param(self, name): return name in self.input - def get_file(self, name): - return self.files.get(name, None) + def has_file(self, name): + return name in self.files + + def get_file(self, name, required=False): + if name in self.files: + return self.files[name] + if not required: + return None + 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: @@ -23,7 +31,8 @@ class Context(object): return param if not required: return default - raise errors.ValidationError('Required paramter %r is missing.' % name) + raise errors.MissingRequiredParameterError( + 'Required paramter %r is missing.' % name) def get_param_as_string(self, name, required=False, default=None): if name in self.input: @@ -32,12 +41,14 @@ class Context(object): try: param = ','.join(param) except: - raise errors.ValidationError( - 'Parameter %r is invalid - expected simple string.' % name) + raise errors.InvalidParameterError( + 'Parameter %r is invalid - expected simple string.' + % name) return param if not required: return default - raise errors.ValidationError('Required paramter %r is missing.' % name) + raise errors.MissingRequiredParameterError( + 'Required paramter %r is missing.' % name) # pylint: disable=redefined-builtin,too-many-arguments def get_param_as_int( @@ -47,21 +58,21 @@ class Context(object): try: val = int(val) except (ValueError, TypeError): - raise errors.ValidationError( + raise errors.InvalidParameterError( 'Parameter %r is invalid: the value must be an integer.' % name) if min is not None and val < min: - raise errors.ValidationError( + 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.ValidationError( + raise errors.InvalidParameterError( 'Parameter %r is invalid: the value may not exceed %r.' % (name, max)) return val if not required: return default - raise errors.ValidationError( + raise errors.MissingRequiredParameterError( 'Required parameter %r is missing.' % name) class Request(falcon.Request): diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py index 436464f..a299dc4 100644 --- a/server/szurubooru/api/post_api.py +++ b/server/szurubooru/api/post_api.py @@ -1,6 +1,29 @@ from szurubooru.api.base_api import BaseApi from szurubooru.func import auth, tags, posts, snapshots, favorites, scores +class PostListApi(BaseApi): + def post(self, ctx): + auth.verify_privilege(ctx.user, 'posts:create') + 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) + source = ctx.get_param_as_string('source', required=False, default=None) + relations = ctx.get_param_as_list('relations', required=False) or [] + 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) + posts.update_post_safety(post, safety) + posts.update_post_source(post, source) + posts.update_post_relations(post, relations) + posts.update_post_notes(post, notes) + posts.update_post_flags(post, flags) + ctx.session.add(post) + snapshots.save_entity_creation(post, ctx.user) + ctx.session.commit() + tags.export_to_json() + return posts.serialize_post_with_details(post, ctx.user) + class PostDetailApi(BaseApi): def get(self, ctx, post_id): auth.verify_privilege(ctx.user, 'posts:view') diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index 85ccda8..f9d16bb 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -65,6 +65,7 @@ def create_app(): app.add_route('/tag-merge/', api.TagMergeApi()) app.add_route('/tag-siblings/{tag_name}', api.TagSiblingsApi()) + app.add_route('/posts/', api.PostListApi()) app.add_route('/post/{post_id}', api.PostDetailApi()) app.add_route('/post/{post_id}/score', api.PostScoreApi()) app.add_route('/post/{post_id}/favorite', api.PostFavoriteApi()) diff --git a/server/szurubooru/db/post.py b/server/szurubooru/db/post.py index 8d6b496..7affa73 100644 --- a/server/szurubooru/db/post.py +++ b/server/szurubooru/db/post.py @@ -71,26 +71,29 @@ class Post(Base): SAFETY_SAFE = 'safe' SAFETY_SKETCHY = 'sketchy' SAFETY_UNSAFE = 'unsafe' - TYPE_IMAGE = 'anim' - TYPE_ANIMATION = 'anim' - TYPE_FLASH = 'flash' + TYPE_IMAGE = 'image' + TYPE_ANIMATION = 'animation' TYPE_VIDEO = 'video' - TYPE_YOUTUBE = 'youtube' - FLAG_LOOP_VIDEO = 1 + TYPE_FLASH = 'flash' + # basic meta post_id = Column('id', Integer, primary_key=True) user_id = Column('user_id', Integer, ForeignKey('user.id')) creation_time = Column('creation_time', DateTime, nullable=False) last_edit_time = Column('last_edit_time', DateTime) safety = Column('safety', String(32), nullable=False) + source = Column('source', String(200)) + flags = Column('flags', PickleType, default=None) + + # content description type = Column('type', String(32), nullable=False) checksum = Column('checksum', String(64), nullable=False) - source = Column('source', String(200)) file_size = Column('file_size', Integer) canvas_width = Column('image_width', Integer) canvas_height = Column('image_height', Integer) - flags = Column('flags', PickleType, default=None) + mime_type = Column('mime-type', String(32), nullable=False) + # foreign tables user = relationship('User') tags = relationship('Tag', backref='posts', secondary='post_tag') relations = relationship( @@ -106,8 +109,9 @@ class Post(Base): 'PostFavorite', cascade='all, delete-orphan', lazy='joined') notes = relationship( 'PostNote', cascade='all, delete-orphan', lazy='joined') - comments = relationship('Comment') + + # dynamic columns tag_count = column_property( select([func.count(PostTag.tag_id)]) \ .where(PostTag.post_id == post_id) \ diff --git a/server/szurubooru/errors.py b/server/szurubooru/errors.py index 60491da..8842382 100644 --- a/server/szurubooru/errors.py +++ b/server/szurubooru/errors.py @@ -5,3 +5,7 @@ class ValidationError(RuntimeError): pass class SearchError(RuntimeError): pass class NotFoundError(RuntimeError): pass class ProcessingError(RuntimeError): pass + +class MissingRequiredFileError(ValidationError): pass +class MissingRequiredParameterError(ValidationError): pass +class InvalidParameterError(ValidationError): pass diff --git a/server/szurubooru/func/files.py b/server/szurubooru/func/files.py index 7091f88..2a0217b 100644 --- a/server/szurubooru/func/files.py +++ b/server/szurubooru/func/files.py @@ -1,8 +1,23 @@ import os from szurubooru import config +def _get_full_path(path): + return os.path.join(config.config['data_dir'], path) + +def delete(path): + full_path = _get_full_path(path) + if os.path.exists(full_path): + os.unlink(full_path) + +def get(path): + full_path = _get_full_path(path) + if not os.path.exists(full_path): + return None + with open(full_path, 'rb') as handle: + return handle.read() + def save(path, content): - full_path = os.path.join(config.config['data_dir'], path) + full_path = _get_full_path(path) os.makedirs(os.path.dirname(full_path), exist_ok=True) with open(full_path, 'wb') as handle: handle.write(content) diff --git a/server/szurubooru/func/images.py b/server/szurubooru/func/images.py index e94d675..741d726 100644 --- a/server/szurubooru/func/images.py +++ b/server/szurubooru/func/images.py @@ -1,3 +1,4 @@ +import json import subprocess from szurubooru import errors @@ -7,6 +8,19 @@ _SCALE_FIT_FMT = \ class Image(object): def __init__(self, content): self.content = content + self._reload_info() + + @property + def width(self): + return self.info['streams'][0]['width'] + + @property + def height(self): + return self.info['streams'][0]['height'] + + @property + def frames(self): + return self.info['streams'][0]['nb_read_frames'] def resize_fill(self, width, height): self.content = self._execute([ @@ -17,6 +31,7 @@ class Image(object): '-vcodec', 'png', '-', ]) + self._reload_info() def to_png(self): return self._execute([ @@ -36,9 +51,9 @@ class Image(object): '-', ]) - def _execute(self, cli): + def _execute(self, cli, program='ffmpeg'): proc = subprocess.Popen( - ['ffmpeg', '-loglevel', '24'] + cli, + [program, '-loglevel', '24'] + cli, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) @@ -47,3 +62,15 @@ class Image(object): raise errors.ProcessingError( 'Error while processing image.\n' + err.decode('utf-8')) return out + + def _reload_info(self): + self.info = json.loads(self._execute([ + '-of', 'json', + '-select_streams', 'v', + '-show_streams', + '-count_frames', + '-i', '-', + ], program='ffprobe').decode('utf-8')) + assert 'streams' in self.info + if len(self.info['streams']) != 1: + raise errors.ProcessingError('Multiple video streams detected.') diff --git a/server/szurubooru/func/mime.py b/server/szurubooru/func/mime.py index a7d26ca..26f17f0 100644 --- a/server/szurubooru/func/mime.py +++ b/server/szurubooru/func/mime.py @@ -2,7 +2,7 @@ import re def get_mime_type(content): if not content: - return None + return 'application/octet-stream' if content[0:3] in (b'CWS', b'FWS', b'ZWS'): return 'application/x-shockwave-flash' @@ -33,7 +33,7 @@ def get_extension(mime_type): 'video/mp4': 'mp4', 'video/webm': 'webm', } - return extension_map.get(mime_type.strip().lower(), None) + return extension_map.get((mime_type or '').strip().lower(), None) def is_flash(mime_type): return mime_type.lower() == 'application/x-shockwave-flash' diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index 7e1524a..b79c117 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -1,10 +1,62 @@ import datetime import sqlalchemy -from szurubooru import db, errors -from szurubooru.func import users, snapshots, scores, comments +from szurubooru import config, db, errors +from szurubooru.func import ( + users, snapshots, scores, comments, tags, util, mime, images, files) + +EMPTY_PIXEL = \ + b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00' \ + b'\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x01\x00\x2c\x00\x00\x00\x00' \ + b'\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b' class PostNotFoundError(errors.NotFoundError): pass class PostAlreadyFeaturedError(errors.ValidationError): pass +class PostAlreadyUploadedError(errors.ValidationError): pass +class InvalidPostSafetyError(errors.ValidationError): pass +class InvalidPostSourceError(errors.ValidationError): pass +class InvalidPostContentError(errors.ValidationError): pass +class InvalidPostRelationError(errors.ValidationError): pass +class InvalidPostNoteError(errors.ValidationError): pass +class InvalidPostFlagError(errors.ValidationError): pass + +SAFETY_MAP = { + db.Post.SAFETY_SAFE: 'safe', + db.Post.SAFETY_SKETCHY: 'sketchy', + db.Post.SAFETY_UNSAFE: 'unsafe', +} +TYPE_MAP = { + db.Post.TYPE_IMAGE: 'image', + db.Post.TYPE_ANIMATION: 'animation', + db.Post.TYPE_VIDEO: 'video', + db.Post.TYPE_FLASH: 'flash', +} + +def get_post_content_url(post): + return '%s/posts/%d.%s' % ( + config.config['data_url'].rstrip('/'), + post.post_id, + mime.get_extension(post.mime_type) or 'dat') + +def get_post_thumbnail_url(post): + return '%s/generated-thumbnails/%d.jpg' % ( + config.config['data_url'].rstrip('/'), + post.post_id) + +def get_post_content_path(post): + return 'posts/%d.%s' % ( + post.post_id, mime.get_extension(post.mime_type) or 'dat') + +def get_post_thumbnail_path(post): + return 'generated-thumbnails/%d.jpg' % (post.post_id) + +def get_post_thumbnail_backup_path(post): + return 'posts/custom-thumbnails/%d.dat' % (post.post_id) + +def serialize_note(note): + return { + 'polygon': note.path, + 'text': note.text, + } def serialize_post(post, authenticated_user): if not post: @@ -14,20 +66,19 @@ def serialize_post(post, authenticated_user): 'id': post.post_id, 'creationTime': post.creation_time, 'lastEditTime': post.last_edit_time, - 'safety': post.safety, - 'type': post.type, - 'checksum': post.checksum, + 'safety': SAFETY_MAP[post.safety], 'source': post.source, + 'type': TYPE_MAP[post.type], + 'checksum': post.checksum, 'fileSize': post.file_size, 'canvasWidth': post.canvas_width, 'canvasHeight': post.canvas_height, + 'contentUrl': get_post_content_url(post), + 'thumbnailUrl': get_post_thumbnail_url(post), 'flags': post.flags, 'tags': [tag.first_name for tag in post.tags], 'relations': [rel.post_id for rel in post.relations], - 'notes': sorted([{ - 'path': note.path, - 'text': note.text, - } for note in post.notes]), + 'notes': sorted(serialize_note(note) for note in post.notes), 'user': users.serialize_user(post.user, authenticated_user), 'score': post.score, 'featureCount': post.feature_count, @@ -75,6 +126,140 @@ def try_get_featured_post(): .first() return post_feature.post if post_feature else None +def create_post(content, tag_names, user): + post = db.Post() + post.safety = db.Post.SAFETY_SAFE + post.user = user + post.creation_time = datetime.datetime.now() + post.flags = [] + + # we'll need post ID + post.type = '' + post.checksum = '' + post.mime_type = '' + db.session.add(post) + db.session.flush() + + update_post_content(post, content) + update_post_tags(post, tag_names) + return post + +def update_post_safety(post, safety): + safety = util.flip(SAFETY_MAP).get(safety, None) + if not safety: + raise InvalidPostSafetyError( + 'Safety can be either of %r.', list(SAFETY_MAP.values())) + post.safety = safety + +def update_post_source(post, source): + if util.value_exceeds_column_size(source, db.Post.source): + raise InvalidPostSourceError('Source is too long.') + post.source = source + +def update_post_content(post, content): + if not content: + raise InvalidPostContentError('Post content missing.') + post.mime_type = mime.get_mime_type(content) + if mime.is_flash(post.mime_type): + post.type = db.Post.TYPE_FLASH + elif mime.is_image(post.mime_type): + if mime.is_animated_gif(content): + post.type = db.Post.TYPE_ANIMATION + else: + post.type = db.Post.TYPE_IMAGE + elif mime.is_video(post.mime_type): + post.type = db.Post.TYPE_VIDEO + else: + raise InvalidPostContentError('Unhandled file type: %r' % post.mime_type) + + post.checksum = util.get_md5(content) + other_post = db.session \ + .query(db.Post) \ + .filter(db.Post.checksum == post.checksum) \ + .filter(db.Post.post_id != post.post_id) \ + .one_or_none() + if other_post: + raise PostAlreadyUploadedError( + 'Post already uploaded (%d)' % other_post.post_id) + + post.file_size = len(content) + try: + image = images.Image(content) + post.canvas_width = image.width + post.canvas_height = image.height + except errors.ProcessingError: + post.canvas_width = None + post.canvas_height = None + files.save(get_post_content_path(post), content) + update_post_thumbnail(post, content=None, delete=False) + +def update_post_thumbnail(post, content=None, delete=True): + if content is None: + content = files.get(get_post_content_path(post)) + if delete: + files.delete(get_post_thumbnail_backup_path(post)) + else: + files.save(get_post_thumbnail_backup_path(post), content) + + try: + image = images.Image(content) + image.resize_fill( + int(config.config['thumbnails']['post_width']), + int(config.config['thumbnails']['post_height'])) + files.save(get_post_thumbnail_path(post), image.to_jpeg()) + except errors.ProcessingError: + files.save(get_post_thumbnail_path(post), EMPTY_PIXEL) + +def update_post_tags(post, tag_names): + existing_tags, new_tags = tags.get_or_create_tags_by_names(tag_names) + post.tags = existing_tags + new_tags + +def update_post_relations(post, post_ids): + relations = db.session \ + .query(db.Post) \ + .filter(db.Post.post_id.in_(post_ids)) \ + .all() + if len(relations) != len(post_ids): + raise InvalidPostRelationError('One of relations does not exist.') + post.relations = relations + +def update_post_notes(post, notes): + post.notes = [] + for note in notes: + for field in ('polygon', 'text'): + if field not in note: + raise InvalidPostNoteError('Note is missing %r field.' % field) + if not note['text']: + raise InvalidPostNoteError('A note\'s text cannot be empty.') + if len(note['polygon']) < 3: + raise InvalidPostNoteError( + 'A note\'s polygon must have at least 3 points.') + for point in note['polygon']: + if len(point) != 2: + raise InvalidPostNoteError( + 'A point in note\'s polygon must have two coordinates.') + try: + pos_x = float(point[0]) + pos_y = float(point[1]) + if not 0 <= pos_x <= 1 or not 0 <= pos_y <= 1: + raise InvalidPostNoteError( + 'A point in note\'s polygon must be in 0..1 range.') + except ValueError: + raise InvalidPostNoteError( + 'A point in note\'s polygon must be numeric.') + if util.value_exceeds_column_size(note['text'], db.PostNote.text): + raise InvalidPostNoteError('Note text is too long.') + post.notes.append( + db.PostNote(polygon=note['polygon'], text=note['text'])) + +def update_post_flags(post, flags): + available_flags = ('loop',) + for flag in flags: + if flag not in available_flags: + raise InvalidPostFlagError( + 'Flag must be one of %r.' % available_flags) + post.flags = flags + def feature_post(post, user): post_feature = db.PostFeature() post_feature.time = datetime.datetime.now() diff --git a/server/szurubooru/func/tag_categories.py b/server/szurubooru/func/tag_categories.py index ea6f2bd..de9df8b 100644 --- a/server/szurubooru/func/tag_categories.py +++ b/server/szurubooru/func/tag_categories.py @@ -77,7 +77,7 @@ def try_get_default_category(): .query(db.TagCategory) \ .order_by(db.TagCategory.tag_category_id.asc()) \ .limit(1) \ - .one() + .first() def get_default_category(): category = try_get_default_category() diff --git a/server/szurubooru/func/tags.py b/server/szurubooru/func/tags.py index 7b8b776..ecb3c99 100644 --- a/server/szurubooru/func/tags.py +++ b/server/szurubooru/func/tags.py @@ -12,6 +12,9 @@ class TagIsInUseError(errors.ValidationError): pass class InvalidTagNameError(errors.ValidationError): pass class InvalidTagRelationError(errors.ValidationError): pass +DEFAULT_CATEGORY_NAME = 'Default' +DEFAULT_CATEGORY_COLOR = 'default' + def _verify_name_validity(name): name_regex = config.config['tag_name_regex'] if not re.match(name_regex, name): @@ -26,6 +29,13 @@ def _lower_list(names): def _check_name_intersection(names1, names2): return len(set(_lower_list(names1)).intersection(_lower_list(names2))) > 0 +def _get_default_category_name(): + tag_category = tag_categories.try_get_default_category() + if tag_category: + return tag_category.name + else: + return DEFAULT_CATEGORY_NAME + def serialize_tag(tag): return { 'names': [tag_name.name for tag_name in tag.names], @@ -104,23 +114,24 @@ def get_or_create_tags_by_names(names): names = util.icase_unique(names) for name in names: _verify_name_validity(name) - related_tags = get_tags_by_names(names) + existing_tags = get_tags_by_names(names) new_tags = [] + tag_category_name = _get_default_category_name() for name in names: found = False - for related_tag in related_tags: - if _check_name_intersection(_get_plain_names(related_tag), [name]): + for existing_tag in existing_tags: + if _check_name_intersection(_get_plain_names(existing_tag), [name]): found = True break if not found: new_tag = create_tag( names=[name], - category_name=tag_categories.get_default_category().name, + category_name=tag_category_name, suggestions=[], implications=[]) db.session.add(new_tag) new_tags.append(new_tag) - return related_tags, new_tags + return existing_tags, new_tags def get_tag_siblings(tag): tag_alias = sqlalchemy.orm.aliased(db.Tag) @@ -159,7 +170,8 @@ def update_tag_category_name(tag, category_name): .filter(db.TagCategory.name == category_name) \ .first() if not category: - category = tag_categories.create_category(category_name, 'default') + category = tag_categories.create_category( + category_name, DEFAULT_CATEGORY_COLOR) db.session.add(category) tag.category = category diff --git a/server/szurubooru/func/users.py b/server/szurubooru/func/users.py index 09581cf..2e17e63 100644 --- a/server/szurubooru/func/users.py +++ b/server/szurubooru/func/users.py @@ -1,5 +1,4 @@ import datetime -import hashlib import re from sqlalchemy import func from szurubooru import config, db, errors @@ -28,11 +27,9 @@ def serialize_user(user, authenticated_user, force_show_email=False): } if user.avatar_style == user.AVATAR_GRAVATAR: - md5 = hashlib.md5() - md5.update((user.email or user.name).lower().encode('utf-8')) - digest = md5.hexdigest() ret['avatarUrl'] = 'http://gravatar.com/avatar/%s?d=retro&s=%d' % ( - digest, config.config['thumbnails']['avatar_width']) + util.get_md5((user.email or user.name).lower()), + config.config['thumbnails']['avatar_width']) else: ret['avatarUrl'] = '%s/avatars/%s.jpg' % ( config.config['data_url'].rstrip('/'), user.name.lower()) diff --git a/server/szurubooru/func/util.py b/server/szurubooru/func/util.py index 83d3931..2fcddd8 100644 --- a/server/szurubooru/func/util.py +++ b/server/szurubooru/func/util.py @@ -1,8 +1,19 @@ import datetime +import hashlib import re from sqlalchemy.inspection import inspect from szurubooru.errors import ValidationError +def get_md5(source): + if not isinstance(source, bytes): + source = source.encode('utf-8') + md5 = hashlib.md5() + md5.update(source) + return md5.hexdigest() + +def flip(source): + return {v: k for k, v in source.items()} + def get_resource_info(entity): serializers = { 'tag': lambda tag: tag.first_name, @@ -96,4 +107,9 @@ def icase_unique(source): return target def value_exceeds_column_size(value, column): - return len(value) > column.property.columns[0].type.length + if not value: + return False + max_length = column.property.columns[0].type.length + if max_length is None: + return False + return len(value) > max_length diff --git a/server/szurubooru/migrations/versions/23abaf4a0a4b_add_mime_type_to_posts.py b/server/szurubooru/migrations/versions/23abaf4a0a4b_add_mime_type_to_posts.py new file mode 100644 index 0000000..8ac336e --- /dev/null +++ b/server/szurubooru/migrations/versions/23abaf4a0a4b_add_mime_type_to_posts.py @@ -0,0 +1,20 @@ +''' +Add mime type to posts + +Revision ID: 23abaf4a0a4b +Created at: 2016-05-02 00:02:33.024885 +''' + +import sqlalchemy as sa +from alembic import op + +revision = '23abaf4a0a4b' +down_revision = 'ed6dd16a30f3' +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column('post', sa.Column('mime-type', sa.String(length=32), nullable=False)) + +def downgrade(): + op.drop_column('post', 'mime-type') diff --git a/server/szurubooru/tests/api/test_comment_creating.py b/server/szurubooru/tests/api/test_comment_creating.py index efa9130..218239c 100644 --- a/server/szurubooru/tests/api/test_comment_creating.py +++ b/server/szurubooru/tests/api/test_comment_creating.py @@ -6,6 +6,7 @@ from szurubooru.func import util, posts @pytest.fixture def test_ctx(config_injector, context_factory, post_factory, user_factory): config_injector({ + 'data_url': 'http://example.com', 'ranks': ['anonymous', 'regular_user'], 'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'}, 'privileges': {'comments:create': 'regular_user'}, diff --git a/server/szurubooru/tests/api/test_comment_rating.py b/server/szurubooru/tests/api/test_comment_rating.py index 73ad4b6..7d10700 100644 --- a/server/szurubooru/tests/api/test_comment_rating.py +++ b/server/szurubooru/tests/api/test_comment_rating.py @@ -6,6 +6,7 @@ from szurubooru.func import util, comments, scores @pytest.fixture def test_ctx(config_injector, context_factory, user_factory, comment_factory): config_injector({ + 'data_url': 'http://example.com', 'ranks': ['anonymous', 'regular_user', 'mod'], 'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'}, 'privileges': { diff --git a/server/szurubooru/tests/api/test_comment_retrieving.py b/server/szurubooru/tests/api/test_comment_retrieving.py index cc9bfcb..b0011b6 100644 --- a/server/szurubooru/tests/api/test_comment_retrieving.py +++ b/server/szurubooru/tests/api/test_comment_retrieving.py @@ -6,6 +6,7 @@ from szurubooru.func import util, comments @pytest.fixture def test_ctx(context_factory, config_injector, user_factory, comment_factory): config_injector({ + 'data_url': 'http://example.com', 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'], 'rank_names': {'regular_user': 'Peasant'}, 'privileges': { diff --git a/server/szurubooru/tests/api/test_comment_updating.py b/server/szurubooru/tests/api/test_comment_updating.py index 0d5297b..08ade5c 100644 --- a/server/szurubooru/tests/api/test_comment_updating.py +++ b/server/szurubooru/tests/api/test_comment_updating.py @@ -6,6 +6,7 @@ from szurubooru.func import util, comments @pytest.fixture def test_ctx(config_injector, context_factory, user_factory, comment_factory): config_injector({ + 'data_url': 'http://example.com', 'ranks': ['anonymous', 'regular_user', 'mod'], 'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord', 'mod': 'King'}, 'privileges': { diff --git a/server/szurubooru/tests/api/test_post_creating.py b/server/szurubooru/tests/api/test_post_creating.py new file mode 100644 index 0000000..9467506 --- /dev/null +++ b/server/szurubooru/tests/api/test_post_creating.py @@ -0,0 +1,133 @@ +import datetime +import os +import unittest.mock +import pytest +from szurubooru import api, db, errors +from szurubooru.func import posts, tags, snapshots + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({ + 'ranks': ['anonymous', 'regular_user'], + 'privileges': {'posts:create': 'regular_user'}, + }) + +def test_creating_minimal_posts( + context_factory, post_factory, user_factory): + auth_user = user_factory(rank='regular_user') + post = post_factory() + db.session.add(post) + 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_with_details'), \ + 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_with_details.return_value = 'serialized post' + + result = api.PostListApi().post( + context_factory( + input={ + 'safety': 'safe', + 'tags': ['tag1', 'tag2'], + }, + files={ + 'content': 'post-content', + }, + user=auth_user)) + + assert result == 'serialized post' + posts.create_post.assert_called_once_with( + 'post-content', ['tag1', 'tag2'], auth_user) + posts.update_post_safety.assert_called_once_with(post, 'safe') + posts.update_post_source.assert_called_once_with(post, None) + posts.update_post_relations.assert_called_once_with(post, []) + posts.update_post_notes.assert_called_once_with(post, []) + posts.update_post_flags.assert_called_once_with(post, []) + posts.serialize_post_with_details.assert_called_once_with(post, auth_user) + tags.export_to_json.assert_called_once_with() + snapshots.save_entity_creation.assert_called_once_with(post, auth_user) + +def test_creating_full_posts(context_factory, post_factory, user_factory): + auth_user = user_factory(rank='regular_user') + post = post_factory() + db.session.add(post) + 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_with_details'), \ + 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_with_details.return_value = 'serialized post' + + result = api.PostListApi().post( + context_factory( + input={ + 'safety': 'safe', + 'tags': ['tag1', 'tag2'], + 'relations': [1, 2], + 'source': 'source', + 'notes': ['note1', 'note2'], + 'flags': ['flag1', 'flag2'], + }, + files={ + 'content': 'post-content', + }, + user=auth_user)) + + assert result == 'serialized post' + posts.create_post.assert_called_once_with( + 'post-content', ['tag1', 'tag2'], auth_user) + posts.update_post_safety.assert_called_once_with(post, 'safe') + posts.update_post_source.assert_called_once_with(post, 'source') + posts.update_post_relations.assert_called_once_with(post, [1, 2]) + posts.update_post_notes.assert_called_once_with(post, ['note1', 'note2']) + posts.update_post_flags.assert_called_once_with(post, ['flag1', 'flag2']) + posts.serialize_post_with_details.assert_called_once_with(post, auth_user) + tags.export_to_json.assert_called_once_with() + snapshots.save_entity_creation.assert_called_once_with(post, auth_user) + +@pytest.mark.parametrize('field', ['tags', 'safety']) +def test_trying_to_omit_mandatory_field(context_factory, user_factory, field): + input = { + 'safety': 'safe', + 'tags': ['tag1', 'tag2'], + } + del input[field] + with pytest.raises(errors.MissingRequiredParameterError): + api.PostListApi().post( + context_factory( + input=input, + files={'content': '...'}, + user=user_factory(rank='regular_user'))) + +def test_trying_to_omit_content(context_factory, user_factory): + with pytest.raises(errors.MissingRequiredFileError): + api.PostListApi().post( + context_factory( + input={ + 'safety': 'safe', + 'tags': ['tag1', 'tag2'], + }, + user=user_factory(rank='regular_user'))) + +def test_trying_to_create_without_privileges(context_factory, user_factory): + with pytest.raises(errors.AuthError): + api.PostListApi().post( + context_factory( + input={'name': 'meta', 'colro': 'black'}, + user=user_factory(rank='anonymous'))) diff --git a/server/szurubooru/tests/api/test_post_favoriting.py b/server/szurubooru/tests/api/test_post_favoriting.py index 30c3f2d..e5e627d 100644 --- a/server/szurubooru/tests/api/test_post_favoriting.py +++ b/server/szurubooru/tests/api/test_post_favoriting.py @@ -6,6 +6,7 @@ from szurubooru.func import util, posts @pytest.fixture def test_ctx(config_injector, context_factory, user_factory, post_factory): config_injector({ + 'data_url': 'http://example.com', 'ranks': ['anonymous', 'regular_user', 'mod'], 'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'}, 'privileges': { diff --git a/server/szurubooru/tests/api/test_post_featuring.py b/server/szurubooru/tests/api/test_post_featuring.py index 005b04b..8a030f3 100644 --- a/server/szurubooru/tests/api/test_post_featuring.py +++ b/server/szurubooru/tests/api/test_post_featuring.py @@ -6,6 +6,7 @@ from szurubooru.func import util, posts @pytest.fixture def test_ctx(context_factory, config_injector, user_factory, post_factory): config_injector({ + 'data_url': 'http://example.com', 'privileges': { 'posts:feature': 'regular_user', 'posts:view': 'regular_user', diff --git a/server/szurubooru/tests/api/test_post_rating.py b/server/szurubooru/tests/api/test_post_rating.py index 65486e5..def80fa 100644 --- a/server/szurubooru/tests/api/test_post_rating.py +++ b/server/szurubooru/tests/api/test_post_rating.py @@ -5,6 +5,7 @@ from szurubooru.func import util, posts, scores @pytest.fixture def test_ctx(config_injector, context_factory, user_factory, post_factory): config_injector({ + 'data_url': 'http://example.com', 'ranks': ['anonymous', 'regular_user'], 'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'}, 'privileges': {'posts:score': 'regular_user'}, diff --git a/server/szurubooru/tests/api/test_post_retrieving.py b/server/szurubooru/tests/api/test_post_retrieving.py index d307b8d..ec39a9a 100644 --- a/server/szurubooru/tests/api/test_post_retrieving.py +++ b/server/szurubooru/tests/api/test_post_retrieving.py @@ -6,6 +6,7 @@ from szurubooru.func import util, posts @pytest.fixture def test_ctx(context_factory, config_injector, user_factory, post_factory): config_injector({ + 'data_url': 'http://example.com', 'privileges': { 'posts:list': 'regular_user', 'posts:view': 'regular_user', diff --git a/server/szurubooru/tests/assets/flash.swf b/server/szurubooru/tests/assets/flash.swf index c6195c41eebe7b3b2006eff39a268320e973feac..c02191cbc516759cf931ad50fbfdde84ea80e9b1 100644 GIT binary patch literal 4790 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

iJGP_ zbsE?d6zLm$MqWQd5%>=6SoYcMkoB`Z7`Z2NrzQwqiNJq~lBr1&lH;9LnL_+zYI*Z` zgeCRo5Wz>I-S_wWJf_isP!^n2O4wxXNmldKi4>z>y0vOU2>3>QXKKAH;OI2D$(jhwtZ{p}qHpb&=|o0V$>YB-!-z?8Y=u$cac zvj@si<*h;(FPkEgZe#`|B;_o+B<))tIN2TX^}*^MG;f;VzGl&*s-~xvM&m(8@I+oR zDvMFCLS~Kp`o%|=pL8p5HUgSZh%ycdCA3I67-Y11j$9I@T#}APO#k>8)|sE_<@d%P zB40A^C);zNC*7Q=Z*XPw_M~I|mr@$4ejzY8e73sVck5*oO;`}~J$KO$M^43)B~if_ zlEVc&Q+#lBB-tG!wNOML&JYOxtQ@pFvX=D6zWJkKUOEvRmFUMNMy}dRhw7~jV^o*| zJKrJpof&$>0vX5a*d?0)Cmgk3-IyTHZ=F)G_D^X<`R%*64?OsGoB}V`(>Xkqzl+x% z@Zpu)F**kHno?8*C}A4+B1ZQ$xKSpW5EU+wdvPNA&CDpB=A&DaN=A|&j>r+u-uvWYg2j{AHxWV9Tmzk`h`@KSw$_!u!+7XA12q1Pi_=zZDEY@jFO!5 zyc5D)ImNX?%qyb(RwNaH2ti2yo|c3FBk{^zsV^@+;N@DO1Gh|_U0No}5KIjk>(2E0 zx&60lH16lYK5G`Aa({}ql+}uiyeQ-J#h8m`z<7_{n{g6>l&h$CH_x^SYHNYlj!cH& z-HH&3`~3<|DgdD~#>`m}7BtAPmW>Ya}wW^vp;a{(Rn1N6KYq4@@oR?jJNh)f|m zxx9U(bXrPIWNb^ZW$~{^AJpO*HD*`JwmMV%y<397w_wVt=i!23@h5rl)NY0%v92jv zpx0PqD|s2^;&rz$Uk@U5_UP!Rn!OJjuH>rjlH~j3H1Yqp8fNmS(vWZ7E6jUy=$NF6 NBnRH3UQTU0{V#n=6odc( literal 140 zcmZ<@4`%OSU|^_VV2x*B;9tPNz{AL3&zuVs>d;|eVaQD_E>28OWk@bcO)NJIrm-+^FmW*ZGdKW+0pm9pO8@`> diff --git a/server/szurubooru/tests/assets/gif.gif b/server/szurubooru/tests/assets/gif.gif index edaf2b97ab8256e904871ce15643d3275dd771e6..a4a9e40d0fb0244fe2b7ee66e3943c31f382113d 100644 GIT binary patch literal 43 lcmZ?wbhEHbWMp7u_`m=Kia%MvEFBODl3`$Ca$#h!1^|Fu1Zw~Q literal 14 TcmZ?wbhEHbWMp7u00L_O6F~vy diff --git a/server/szurubooru/tests/assets/jpeg.jpg b/server/szurubooru/tests/assets/jpeg.jpg index 71911bf48766c7181518c1070911019fbb00b1fc..2f0b00fef9f7b78e61b2ccc597ca808f541cc7e9 100644 GIT binary patch literal 12656 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 literal 0 HcmV?d00001 diff --git a/server/szurubooru/tests/assets/png-transparent.png b/server/szurubooru/tests/assets/png-transparent.png deleted file mode 100644 index 91a99b94e23a00cc8133f22e3fe2a0b48a015808..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 67 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blE>9Q7kcv6UAQ@H$MqV!6EkIEQ MPgg&ebxsLQ07X&@0{{R3 diff --git a/server/szurubooru/tests/assets/png.png b/server/szurubooru/tests/assets/png.png new file mode 100644 index 0000000000000000000000000000000000000000..b7923db936f56c49ecb171bf9ec51c99c9424971 GIT binary patch literal 15332 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

Wu`&xxFkxjDOfaR1PmQx{ro-GL*= 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