diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 18c00293..8ffc16ee 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -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() diff --git a/app/activitypub/util.py b/app/activitypub/util.py index beb37e92..5f749e75 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -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': diff --git a/app/community/routes.py b/app/community/routes.py index 7e94df0f..99949cc3 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -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, \ diff --git a/app/community/util.py b/app/community/util.py index ffb1c543..41c5c060 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -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 \ No newline at end of file diff --git a/app/main/routes.py b/app/main/routes.py index b75b49c7..6073b67c 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -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(): diff --git a/app/models.py b/app/models.py index cbd8f1f7..9dff89de 100644 --- a/app/models.py +++ b/app/models.py @@ -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: diff --git a/app/static/structure.css b/app/static/structure.css index 8f95894d..c66b9b57 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -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; diff --git a/app/static/structure.scss b/app/static/structure.scss index 103458f5..e618fab6 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -315,6 +315,8 @@ nav, etc which are used site-wide */ img { width: 20px; height: 20px; + border-radius: 50%; + vertical-align: bottom; } } diff --git a/app/templates/base.html b/app/templates/base.html index 4c13c381..489b46b7 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -5,7 +5,7 @@ {% else %} {% if user.avatar_id %} - Avatar + Avatar {% endif %} {{ user.user_name }} {% if user.created_recently() %} diff --git a/app/templates/user/show_profile.html b/app/templates/user/show_profile.html index c1875814..de3d74d7 100644 --- a/app/templates/user/show_profile.html +++ b/app/templates/user/show_profile.html @@ -34,10 +34,9 @@

{{ user.user_name if user.ap_id == none else user.ap_id }}

-

{{ _('Joined') }}: {{ moment(user.created).fromNow(refresh=True) }} - {{ user.about_html|safe }} {% endif %} - +

{{ _('Joined') }}: {{ moment(user.created).fromNow(refresh=True) }} + {{ user.about_html|safe }} {% if posts %}

Posts

diff --git a/app/user/routes.py b/app/user/routes.py index 50fdb52b..bb72d406 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -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 != '': diff --git a/app/utils.py b/app/utils.py index 5f25102e..1881fa8e 100644 --- a/app/utils.py +++ b/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)