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, \ 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, \ 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, \ 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, \ 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 domain_from_url, markdown_to_html, community_membership, ap_datetime
import werkzeug.exceptions import werkzeug.exceptions
@ -401,6 +401,10 @@ def process_inbox_request(request_json, activitypublog_id):
community.last_active = utcnow() community.last_active = utcnow()
activity_log.result = 'success' activity_log.result = 'success'
db.session.commit() 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, vote = PostVote(user_id=user.id, author_id=post.user_id,
post_id=post.id, post_id=post.id,
effect=instance_weight(user.ap_domain)) effect=instance_weight(user.ap_domain))
@ -442,6 +446,12 @@ def process_inbox_request(request_json, activitypublog_id):
activity_log.exception_message = 'Comments disabled' activity_log.exception_message = 'Comments disabled'
else: else:
activity_log.exception_message = 'Unacceptable type (kbin): ' + object_type 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 (?) # Announce is new content and votes, mastodon style (?)
if request_json['type'] == 'Announce': if request_json['type'] == 'Announce':
@ -502,6 +512,8 @@ def process_inbox_request(request_json, activitypublog_id):
db.session.add(post) db.session.add(post)
community.post_count += 1 community.post_count += 1
db.session.commit() db.session.commit()
if post.image_id:
make_image_sizes(post.image_id, 266, None, 'posts')
else: else:
post_id, parent_comment_id, root_id = find_reply_parent(in_reply_to) post_id, parent_comment_id, root_id = find_reply_parent(in_reply_to)
if post_id or parent_comment_id or root_id: 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' activity_log.exception_message = 'Parent not found'
else: else:
activity_log.exception_message = 'Unacceptable type: ' + object_type 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': elif request_json['object']['type'] == 'Like':
activity_log.activity_type = request_json['object']['type'] activity_log.activity_type = request_json['object']['type']
@ -558,8 +576,16 @@ def process_inbox_request(request_json, activitypublog_id):
else: else:
activity_log.exception_message = 'Could not detect type of like' activity_log.exception_message = 'Could not detect type of like'
if activity_log.result == 'success': 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 # 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': elif request_json['object']['type'] == 'Dislike':
activity_log.activity_type = request_json['object']['type'] activity_log.activity_type = request_json['object']['type']
if site.enable_downvotes is False: if site.enable_downvotes is False:
@ -584,6 +610,12 @@ def process_inbox_request(request_json, activitypublog_id):
if activity_log.result == 'success': 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 # 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 # 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 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 comment.score -= existing_vote.effect
db.session.delete(existing_vote) db.session.delete(existing_vote)
activity_log.result = 'success' 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 elif request_json['object']['type'] == 'Dislike': # Undoing a downvote - probably unused
activity_log.activity_type = request_json['object']['type'] 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) db.session.delete(existing_vote)
activity_log.result = 'success' 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': elif request_json['type'] == 'Update':
if request_json['object']['type'] == 'Page': # Editing a post if request_json['object']['type'] == 'Page': # Editing a post
post = Post.query.filter_by(ap_id=request_json['object']['id']).first() 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: else:
activity_log.exception_message = 'Instance banned' 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' activity_log.result = 'failure'
db.session.commit() db.session.commit()

View file

@ -2,12 +2,13 @@ from __future__ import annotations
import json import json
import os import os
from random import randint
from typing import Union, Tuple from typing import Union, Tuple
from flask import current_app, request, g from flask import current_app, request, g
from sqlalchemy import text 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, \ from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow, \
Site, PostVote, PostReplyVote, ActivityPubLog PostVote, PostReplyVote, ActivityPubLog
import time import time
import base64 import base64
import requests import requests
@ -15,9 +16,11 @@ from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.asymmetric import padding
from app.constants import * from app.constants import *
from urllib.parse import urlparse 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, \ 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(): def public_key():
@ -256,17 +259,19 @@ def actor_json_to_model(activity_json, address, server):
# language=community_json['language'][0]['identifier'] # todo: language # language=community_json['language'][0]['identifier'] # todo: language
) )
if 'icon' in activity_json: if 'icon' in activity_json:
# todo: retrieve icon, save to disk, save more complete File record
avatar = File(source_url=activity_json['icon']['url']) avatar = File(source_url=activity_json['icon']['url'])
user.avatar = avatar user.avatar = avatar
db.session.add(avatar) db.session.add(avatar)
if 'image' in activity_json: if 'image' in activity_json:
# todo: retrieve image, save to disk, save more complete File record
cover = File(source_url=activity_json['image']['url']) cover = File(source_url=activity_json['image']['url'])
user.cover = cover user.cover = cover
db.session.add(cover) db.session.add(cover)
db.session.add(user) db.session.add(user)
db.session.commit() 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 return user
elif activity_json['type'] == 'Group': elif activity_json['type'] == 'Group':
if 'attributedTo' in activity_json: # lemmy and mbin 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 = allowlist_html(activity_json['content'])
community.description = html_to_markdown(community.description_html) community.description = html_to_markdown(community.description_html)
if 'icon' in activity_json: if 'icon' in activity_json:
# todo: retrieve icon, save to disk, save more complete File record
icon = File(source_url=activity_json['icon']['url']) icon = File(source_url=activity_json['icon']['url'])
community.icon = icon community.icon = icon
db.session.add(icon) db.session.add(icon)
if 'image' in activity_json: if 'image' in activity_json:
# todo: retrieve image, save to disk, save more complete File record
image = File(source_url=activity_json['image']['url']) image = File(source_url=activity_json['image']['url'])
community.image = image community.image = image
db.session.add(image) db.session.add(image)
db.session.add(community) db.session.add(community)
db.session.commit() 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 return community
@ -364,6 +371,71 @@ def post_json_to_model(post_json, user, community) -> Post:
db.session.commit() db.session.commit()
return post 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 # create a summary from markdown if present, otherwise use html if available
def parse_summary(user_json) -> str: def parse_summary(user_json) -> str:
if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown': 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, \ from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm, ReportCommunityForm, \
DeleteCommunityForm DeleteCommunityForm
from app.community.util import search_for_community, community_url_exists, actor_to_community, \ 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, \ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \
SUBSCRIPTION_PENDING SUBSCRIPTION_PENDING
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ 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.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.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, \ 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 from sqlalchemy import desc, text
import os import os
from opengraph_parse import parse_page from opengraph_parse import parse_page
@ -121,16 +121,6 @@ def actor_to_community(actor) -> Community:
return 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) @cache.memoize(timeout=50)
def opengraph_parse(url): def opengraph_parse(url):
try: try:
@ -297,13 +287,13 @@ def save_icon_file(icon_file) -> File:
img = ImageOps.exif_transpose(img) img = ImageOps.exif_transpose(img)
img_width = img.width img_width = img.width
img_height = img.height img_height = img.height
if img.width > 200 or img.height > 200: if img.width > 250 or img.height > 250:
img.thumbnail((200, 200)) img.thumbnail((250, 250))
img.save(final_place) img.save(final_place)
img_width = img.width img_width = img.width
img_height = img.height img_height = img.height
# save a second, smaller, version as a thumbnail # 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) img.save(final_place_thumbnail, format="WebP", quality=93)
thumbnail_width = img.width thumbnail_width = img.width
thumbnail_height = img.height thumbnail_height = img.height
@ -340,13 +330,19 @@ def save_banner_file(banner_file) -> File:
img = ImageOps.exif_transpose(img) img = ImageOps.exif_transpose(img)
img_width = img.width img_width = img.width
img_height = img.height img_height = img.height
if img.width > 1000 or img.height > 300: if img.width > 1600 or img.height > 600:
img.thumbnail((1000, 300)) img.thumbnail((1600, 600))
img.save(final_place) img.save(final_place)
img_width = img.width img_width = img.width
img_height = img.height 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', 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) db.session.add(file)
return file return file

View file

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

View file

@ -289,6 +289,17 @@ class User(UserMixin, db.Model):
else: else:
return '[deleted]' 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: def avatar_image(self) -> str:
if self.avatar_id is not None: if self.avatar_id is not None:
if self.avatar.file_path is not None: if self.avatar.file_path is not None:

View file

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

View file

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

View file

@ -5,7 +5,7 @@
{% else %} {% else %}
{% if user.avatar_id %} {% if user.avatar_id %}
<a href="/u/{{ user.link() }}" title="{{ user.ap_id if user.ap_id != none else user.user_name }}"> <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 %} {% endif %}
<a href="/u/{{ user.link() }}" title="{{ user.ap_id if user.ap_id != none else user.user_name }}">{{ user.user_name }}</a> <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() %} {% if user.created_recently() %}

View file

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

View file

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

View file

@ -256,6 +256,16 @@ def retrieve_block_list():
return response.text 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): def validate_image(stream):
header = stream.read(512) header = stream.read(512)
stream.seek(0) stream.seek(0)