mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 11:26:56 -08:00
move long-running tasks to separate background process (image processing)
This commit is contained in:
parent
6182240ad3
commit
64e0193135
12 changed files with 173 additions and 34 deletions
|
@ -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()
|
||||
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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, \
|
||||
|
|
|
@ -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
|
|
@ -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():
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -315,6 +315,8 @@ nav, etc which are used site-wide */
|
|||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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() %}
|
||||
|
|
|
@ -34,10 +34,9 @@
|
|||
</ol>
|
||||
</nav>
|
||||
<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 %}
|
||||
|
||||
<p class="small">{{ _('Joined') }}: {{ moment(user.created).fromNow(refresh=True) }}
|
||||
{{ user.about_html|safe }}
|
||||
{% if posts %}
|
||||
<h2 class="mt-4">Posts</h2>
|
||||
<div class="post_list">
|
||||
|
|
|
@ -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 != '':
|
||||
|
|
10
app/utils.py
10
app/utils.py
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue