move long-running tasks to separate background process (image processing)

This commit is contained in:
rimu 2023-12-24 16:20:18 +13:00
parent 6182240ad3
commit 64e0193135
12 changed files with 173 additions and 34 deletions

View file

@ -12,7 +12,7 @@ from app.models import User, Community, CommunityJoinRequest, CommunityMember, C
from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \
post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \
lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \
upvote_post, activity_already_ingested
upvote_post, activity_already_ingested, make_image_sizes
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \
domain_from_url, markdown_to_html, community_membership, ap_datetime
import werkzeug.exceptions
@ -401,6 +401,10 @@ def process_inbox_request(request_json, activitypublog_id):
community.last_active = utcnow()
activity_log.result = 'success'
db.session.commit()
if post.image_id:
make_image_sizes(post.image_id, 266, None, 'posts')
vote = PostVote(user_id=user.id, author_id=post.user_id,
post_id=post.id,
effect=instance_weight(user.ap_domain))
@ -442,6 +446,12 @@ def process_inbox_request(request_json, activitypublog_id):
activity_log.exception_message = 'Comments disabled'
else:
activity_log.exception_message = 'Unacceptable type (kbin): ' + object_type
else:
if user is None or community is None:
activity_log.exception_message = 'Blocked or unfound user or community'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
# Announce is new content and votes, mastodon style (?)
if request_json['type'] == 'Announce':
@ -502,6 +512,8 @@ def process_inbox_request(request_json, activitypublog_id):
db.session.add(post)
community.post_count += 1
db.session.commit()
if post.image_id:
make_image_sizes(post.image_id, 266, None, 'posts')
else:
post_id, parent_comment_id, root_id = find_reply_parent(in_reply_to)
if post_id or parent_comment_id or root_id:
@ -538,6 +550,12 @@ def process_inbox_request(request_json, activitypublog_id):
activity_log.exception_message = 'Parent not found'
else:
activity_log.exception_message = 'Unacceptable type: ' + object_type
else:
if user is None or community is None:
activity_log.exception_message = 'Blocked or unfound user or community'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif request_json['object']['type'] == 'Like':
activity_log.activity_type = request_json['object']['type']
@ -558,8 +576,16 @@ def process_inbox_request(request_json, activitypublog_id):
else:
activity_log.exception_message = 'Could not detect type of like'
if activity_log.result == 'success':
... # todo: recalculate 'hotness' of liked post/reply
...
# todo: recalculate 'hotness' of liked post/reply
# todo: if vote was on content in local community, federate the vote out to followers
else:
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif request_json['object']['type'] == 'Dislike':
activity_log.activity_type = request_json['object']['type']
if site.enable_downvotes is False:
@ -584,6 +610,12 @@ def process_inbox_request(request_json, activitypublog_id):
if activity_log.result == 'success':
... # todo: recalculate 'hotness' of liked post/reply
# todo: if vote was on content in local community, federate the vote out to followers
else:
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
# Follow: remote user wants to join/follow one of our communities
elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community
@ -699,6 +731,12 @@ def process_inbox_request(request_json, activitypublog_id):
comment.score -= existing_vote.effect
db.session.delete(existing_vote)
activity_log.result = 'success'
else:
if user is None or comment is None:
activity_log.exception_message = 'Blocked or unfound user or comment'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif request_json['object']['type'] == 'Dislike': # Undoing a downvote - probably unused
activity_log.activity_type = request_json['object']['type']
@ -729,6 +767,12 @@ def process_inbox_request(request_json, activitypublog_id):
db.session.delete(existing_vote)
activity_log.result = 'success'
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif request_json['type'] == 'Update':
if request_json['object']['type'] == 'Page': # Editing a post
post = Post.query.filter_by(ap_id=request_json['object']['id']).first()
@ -820,7 +864,7 @@ def process_inbox_request(request_json, activitypublog_id):
else:
activity_log.exception_message = 'Instance banned'
if activity_log.exception_message is not None:
if activity_log.exception_message is not None and activity_log.result == 'processing':
activity_log.result = 'failure'
db.session.commit()

View file

@ -2,12 +2,13 @@ from __future__ import annotations
import json
import os
from random import randint
from typing import Union, Tuple
from flask import current_app, request, g
from sqlalchemy import text
from app import db, cache, constants
from app import db, cache, constants, celery
from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow, \
Site, PostVote, PostReplyVote, ActivityPubLog
PostVote, PostReplyVote, ActivityPubLog
import time
import base64
import requests
@ -15,9 +16,11 @@ from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from app.constants import *
from urllib.parse import urlparse
from PIL import Image, ImageOps
from io import BytesIO
from app.utils import get_request, allowlist_html, html_to_markdown, get_setting, ap_datetime, markdown_to_html, \
is_image_url, domain_from_url
is_image_url, domain_from_url, gibberish, ensure_directory_exists
def public_key():
@ -256,17 +259,19 @@ def actor_json_to_model(activity_json, address, server):
# language=community_json['language'][0]['identifier'] # todo: language
)
if 'icon' in activity_json:
# todo: retrieve icon, save to disk, save more complete File record
avatar = File(source_url=activity_json['icon']['url'])
user.avatar = avatar
db.session.add(avatar)
if 'image' in activity_json:
# todo: retrieve image, save to disk, save more complete File record
cover = File(source_url=activity_json['image']['url'])
user.cover = cover
db.session.add(cover)
db.session.add(user)
db.session.commit()
if user.avatar_id:
make_image_sizes(user.avatar_id, 40, 250, 'users')
if user.cover_id:
make_image_sizes(user.cover_id, 700, 1600, 'users')
return user
elif activity_json['type'] == 'Group':
if 'attributedTo' in activity_json: # lemmy and mbin
@ -306,17 +311,19 @@ def actor_json_to_model(activity_json, address, server):
community.description_html = allowlist_html(activity_json['content'])
community.description = html_to_markdown(community.description_html)
if 'icon' in activity_json:
# todo: retrieve icon, save to disk, save more complete File record
icon = File(source_url=activity_json['icon']['url'])
community.icon = icon
db.session.add(icon)
if 'image' in activity_json:
# todo: retrieve image, save to disk, save more complete File record
image = File(source_url=activity_json['image']['url'])
community.image = image
db.session.add(image)
db.session.add(community)
db.session.commit()
if community.icon_id:
make_image_sizes(community.icon_id, 40, 250, 'communities')
if community.image_id:
make_image_sizes(community.image_id, 700, 1600, 'communities')
return community
@ -364,6 +371,71 @@ def post_json_to_model(post_json, user, community) -> Post:
db.session.commit()
return post
# Save two different versions of a File, after downloading it from file.source_url. Set a width parameter to None to avoid generating one of that size
def make_image_sizes(file_id, thumbnail_width=50, medium_width=120, directory='posts'):
if current_app.debug:
make_image_sizes_async(file_id, thumbnail_width, medium_width, directory)
else:
make_image_sizes_async.apply_async(args=(file_id, thumbnail_width, medium_width, directory), countdown=randint(1, 10)) # Delay by up to 10 seconds so servers do not experience a stampede of requests all in the same second
@celery.task
def make_image_sizes_async(file_id, thumbnail_width, medium_width, directory):
file = File.query.get(file_id)
if file and file.source_url:
try:
source_image_response = get_request(file.source_url)
except:
pass
else:
if source_image_response.status_code == 200:
content_type = source_image_response.headers.get('content-type')
if content_type and content_type.startswith('image'):
source_image = source_image_response.content
source_image_response.close()
file_ext = os.path.splitext(file.source_url)[1]
new_filename = gibberish(15)
# set up the storage directory
directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
# file path and names to store the resized images on disk
final_place = os.path.join(directory, new_filename + file_ext)
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
# Load image data into Pillow
image = Image.open(BytesIO(source_image))
image = ImageOps.exif_transpose(image)
img_width = image.width
img_height = image.height
# Resize the image to medium
if medium_width:
if img_width > medium_width:
image.thumbnail((medium_width, medium_width))
image.save(final_place)
file.file_path = final_place
file.width = image.width
file.height = image.height
# Resize the image to a thumbnail (webp)
if thumbnail_width:
if img_width > thumbnail_width:
image.thumbnail((thumbnail_width, thumbnail_width))
image.save(final_place_thumbnail, format="WebP", quality=93)
file.thumbnail_path = final_place_thumbnail
file.thumbnail_width = image.width
file.thumbnail_height = image.height
db.session.commit()
# create a summary from markdown if present, otherwise use html if available
def parse_summary(user_json) -> str:
if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown':

View file

@ -9,7 +9,7 @@ from app.activitypub.util import default_context
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm, ReportCommunityForm, \
DeleteCommunityForm
from app.community.util import search_for_community, community_url_exists, actor_to_community, \
ensure_directory_exists, opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file
opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \
SUBSCRIPTION_PENDING
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \

View file

@ -13,7 +13,7 @@ from app.activitypub.util import find_actor_or_create, actor_json_to_model, post
from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE
from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember
from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, validate_image, allowlist_html, \
html_to_markdown, is_image_url
html_to_markdown, is_image_url, ensure_directory_exists
from sqlalchemy import desc, text
import os
from opengraph_parse import parse_page
@ -121,16 +121,6 @@ def actor_to_community(actor) -> Community:
return community
def ensure_directory_exists(directory):
parts = directory.split('/')
rebuild_directory = ''
for part in parts:
rebuild_directory += part
if not os.path.isdir(rebuild_directory):
os.mkdir(rebuild_directory)
rebuild_directory += '/'
@cache.memoize(timeout=50)
def opengraph_parse(url):
try:
@ -297,13 +287,13 @@ def save_icon_file(icon_file) -> File:
img = ImageOps.exif_transpose(img)
img_width = img.width
img_height = img.height
if img.width > 200 or img.height > 200:
img.thumbnail((200, 200))
if img.width > 250 or img.height > 250:
img.thumbnail((250, 250))
img.save(final_place)
img_width = img.width
img_height = img.height
# save a second, smaller, version as a thumbnail
img.thumbnail((32, 32))
img.thumbnail((40, 40))
img.save(final_place_thumbnail, format="WebP", quality=93)
thumbnail_width = img.width
thumbnail_height = img.height
@ -340,13 +330,19 @@ def save_banner_file(banner_file) -> File:
img = ImageOps.exif_transpose(img)
img_width = img.width
img_height = img.height
if img.width > 1000 or img.height > 300:
img.thumbnail((1000, 300))
if img.width > 1600 or img.height > 600:
img.thumbnail((1600, 600))
img.save(final_place)
img_width = img.width
img_height = img.height
# save a second, smaller, version as a thumbnail
img.thumbnail((700, 500))
img.save(final_place_thumbnail, format="WebP", quality=93)
thumbnail_width = img.width
thumbnail_height = img.height
file = File(file_path=final_place, file_name=new_filename + file_ext, alt_text='community banner',
width=img_width, height=img_height)
width=img_width, height=img_height, thumbnail_width=thumbnail_width, thumbnail_height=thumbnail_height)
db.session.add(file)
return file

View file

@ -1,7 +1,7 @@
from sqlalchemy.sql.operators import or_
from app import db, cache
from app.activitypub.util import default_context
from app.activitypub.util import default_context, make_image_sizes_async
from app.constants import SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER, POST_TYPE_IMAGE, POST_TYPE_LINK, SUBSCRIPTION_OWNER
from app.main import bp
from flask import g, session, flash, request, current_app, url_for, redirect, make_response, jsonify
@ -99,7 +99,9 @@ def robots():
@bp.route('/test')
def test():
...
make_image_sizes_async(140, 40, 250, 'communities')
make_image_sizes_async(141, 700, 1600, 'communities')
return 'done'
def verification_warning():

View file

@ -289,6 +289,17 @@ class User(UserMixin, db.Model):
else:
return '[deleted]'
def avatar_thumbnail(self) -> str:
if self.avatar_id is not None:
if self.avatar.thumbnail_path is not None:
if self.avatar.thumbnail_path.startswith('app/'):
return self.avatar.thumbnail_path.replace('app/', '/')
else:
return self.avatar.thumbnail_path
else:
return self.avatar_image()
return ''
def avatar_image(self) -> str:
if self.avatar_id is not None:
if self.avatar.file_path is not None:

View file

@ -587,6 +587,8 @@ fieldset legend {
.comment .comment_author img {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: bottom;
}
.comment .hide_button {
float: right;

View file

@ -315,6 +315,8 @@ nav, etc which are used site-wide */
img {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: bottom;
}
}

View file

@ -5,7 +5,7 @@
{% else %}
{% if user.avatar_id %}
<a href="/u/{{ user.link() }}" title="{{ user.ap_id if user.ap_id != none else user.user_name }}">
<img src="{{ user.avatar_image() }}" alt="Avatar" /></a>
<img src="{{ user.avatar_thumbnail() }}" alt="Avatar" /></a>
{% endif %}
<a href="/u/{{ user.link() }}" title="{{ user.ap_id if user.ap_id != none else user.user_name }}">{{ user.user_name }}</a>
{% if user.created_recently() %}

View file

@ -34,10 +34,9 @@
</ol>
</nav>
<h1 class="mt-2">{{ user.user_name if user.ap_id == none else user.ap_id }}</h1>
{% endif %}
<p class="small">{{ _('Joined') }}: {{ moment(user.created).fromNow(refresh=True) }}
{{ user.about_html|safe }}
{% endif %}
{% if posts %}
<h2 class="mt-4">Posts</h2>
<div class="post_list">

View file

@ -68,6 +68,7 @@ def edit_profile(actor):
if form.password_field.data.strip() != '':
current_user.set_password(form.password_field.data)
current_user.about = form.about.data
current_user.about_html = markdown_to_html(form.about.data)
current_user.bot = form.bot.data
profile_file = request.files['profile_file']
if profile_file and profile_file.filename != '':

View file

@ -256,6 +256,16 @@ def retrieve_block_list():
return response.text
def ensure_directory_exists(directory):
parts = directory.split('/')
rebuild_directory = ''
for part in parts:
rebuild_directory += part
if not os.path.isdir(rebuild_directory):
os.mkdir(rebuild_directory)
rebuild_directory += '/'
def validate_image(stream):
header = stream.read(512)
stream.seek(0)