Merge pull request 'Allow reuploading images in image type posts' (#385) from xmatt/pyfedi:feature/image_post_editing into main

Reviewed-on: https://codeberg.org/rimu/pyfedi/pulls/385
This commit is contained in:
rimu 2024-12-30 00:18:16 +00:00
commit 8088aa1b3c
8 changed files with 111 additions and 70 deletions

View file

@ -178,8 +178,9 @@ class CreateImageForm(CreatePostForm):
return True return True
class EditImageForm(CreatePostForm): class EditImageForm(CreateImageForm):
image_alt_text = StringField(_l('Alt text'), validators=[Optional(), Length(min=3, max=1500)]) image_file = FileField(_l('Replace Image'), validators=[DataRequired()], render_kw={'accept': 'image/*'})
image_file = FileField(_l('Image'), validators=[Optional()], render_kw={'accept': 'image/*'})
def validate(self, extra_validators=None) -> bool: def validate(self, extra_validators=None) -> bool:
if self.communities: if self.communities:

View file

@ -690,8 +690,13 @@ def add_post(actor, type):
img.thumbnail((2000, 2000)) img.thumbnail((2000, 2000))
img.save(final_place) img.save(final_place)
request_json['object']['attachment'] = [{'type': 'Image', 'url': f'https://{current_app.config["SERVER_NAME"]}/{final_place.replace("app/", "")}', request_json['object']['attachment'] = [{
'name': form.image_alt_text.data}] 'type': 'Image',
'url': f'https://{current_app.config["SERVER_NAME"]}/{final_place.replace("app/", "")}',
'name': form.image_alt_text.data,
'file_path': final_place
}]
elif type == 'video': elif type == 'video':
request_json['object']['attachment'] = [{'type': 'Document', 'url': form.video_url.data}] request_json['object']['attachment'] = [{'type': 'Document', 'url': form.video_url.data}]
elif type == 'poll': elif type == 'poll':

View file

@ -304,66 +304,72 @@ def save_post(form, post: Post, type: int):
elif type == POST_TYPE_IMAGE: elif type == POST_TYPE_IMAGE:
post.type = POST_TYPE_IMAGE post.type = POST_TYPE_IMAGE
alt_text = form.image_alt_text.data if form.image_alt_text.data else form.title.data alt_text = form.image_alt_text.data if form.image_alt_text.data else form.title.data
if post.image_id is not None: uploaded_file = request.files['image_file']
# editing an existing image post, dont try an upload # If we are uploading new file in the place of existing one just remove the old one
pass if post.image_id is not None and uploaded_file:
else: post.image.delete_from_disk()
uploaded_file = request.files['image_file'] image_id = post.image_id
if uploaded_file and uploaded_file.filename != '': post.image_id = None
if post.image_id: db.session.add(post)
remove_old_file(post.image_id) db.session.commit()
post.image_id = None File.query.filter_by(id=image_id).delete()
# check if this is an allowed type of file if uploaded_file and uploaded_file.filename != '':
file_ext = os.path.splitext(uploaded_file.filename)[1] if post.image_id:
if file_ext.lower() not in allowed_extensions: remove_old_file(post.image_id)
abort(400) post.image_id = None
new_filename = gibberish(15)
# set up the storage directory # check if this is an allowed type of file
directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4] file_ext = os.path.splitext(uploaded_file.filename)[1]
ensure_directory_exists(directory) if file_ext.lower() not in allowed_extensions:
abort(400)
new_filename = gibberish(15)
# save the file # set up the storage directory
final_place = os.path.join(directory, new_filename + file_ext) directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4]
final_place_medium = os.path.join(directory, new_filename + '_medium.webp') ensure_directory_exists(directory)
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
uploaded_file.seek(0)
uploaded_file.save(final_place)
if file_ext.lower() == '.heic': # save the file
register_heif_opener() final_place = os.path.join(directory, new_filename + file_ext)
final_place_medium = os.path.join(directory, new_filename + '_medium.webp')
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
uploaded_file.seek(0)
uploaded_file.save(final_place)
Image.MAX_IMAGE_PIXELS = 89478485 if file_ext.lower() == '.heic':
register_heif_opener()
# resize if necessary Image.MAX_IMAGE_PIXELS = 89478485
img = Image.open(final_place)
if '.' + img.format.lower() in allowed_extensions:
img = ImageOps.exif_transpose(img)
# limit full sized version to 2000px # resize if necessary
img_width = img.width img = Image.open(final_place)
img_height = img.height if '.' + img.format.lower() in allowed_extensions:
img.thumbnail((2000, 2000)) img = ImageOps.exif_transpose(img)
img.save(final_place)
# medium sized version # limit full sized version to 2000px
img.thumbnail((512, 512)) img_width = img.width
img.save(final_place_medium, format="WebP", quality=93) img_height = img.height
img.thumbnail((2000, 2000))
img.save(final_place)
# save a third, smaller, version as a thumbnail # medium sized version
img.thumbnail((170, 170)) img.thumbnail((512, 512))
img.save(final_place_thumbnail, format="WebP", quality=93) img.save(final_place_medium, format="WebP", quality=93)
thumbnail_width = img.width
thumbnail_height = img.height # save a third, smaller, version as a thumbnail
img.thumbnail((170, 170))
img.save(final_place_thumbnail, format="WebP", quality=93)
thumbnail_width = img.width
thumbnail_height = img.height
file = File(file_path=final_place_medium, file_name=new_filename + file_ext, alt_text=alt_text,
width=img_width, height=img_height, thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail,
source_url=final_place.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/"))
db.session.add(file)
db.session.commit()
post.image_id = file.id
file = File(file_path=final_place_medium, file_name=new_filename + file_ext, alt_text=alt_text,
width=img_width, height=img_height, thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail,
source_url=final_place.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/"))
db.session.add(file)
db.session.commit()
post.image_id = file.id
elif type == POST_TYPE_VIDEO: elif type == POST_TYPE_VIDEO:
form.video_url.data = form.video_url.data.strip() form.video_url.data = form.video_url.data.strip()
url_changed = post.id is None or form.video_url.data != post.url url_changed = post.id is None or form.video_url.data != post.url

View file

@ -329,7 +329,6 @@ class File(db.Model):
if purge_from_cache: if purge_from_cache:
flush_cdn_cache(purge_from_cache) flush_cdn_cache(purge_from_cache)
def filesize(self): def filesize(self):
size = 0 size = 0
if self.file_path and os.path.exists(self.file_path): if self.file_path and os.path.exists(self.file_path):
@ -1246,6 +1245,7 @@ class Post(db.Model):
if blocked_phrase in post.body: if blocked_phrase in post.body:
return None return None
file_path = None
if ('attachment' in request_json['object'] and if ('attachment' in request_json['object'] and
isinstance(request_json['object']['attachment'], list) and isinstance(request_json['object']['attachment'], list) and
len(request_json['object']['attachment']) > 0 and len(request_json['object']['attachment']) > 0 and
@ -1258,9 +1258,10 @@ class Post(db.Model):
if 'name' in request_json['object']['attachment'][0]: if 'name' in request_json['object']['attachment'][0]:
alt_text = request_json['object']['attachment'][0]['name'] alt_text = request_json['object']['attachment'][0]['name']
if request_json['object']['attachment'][0]['type'] == 'Image': if request_json['object']['attachment'][0]['type'] == 'Image':
post.url = request_json['object']['attachment'][0]['url'] # PixelFed, PieFed, Lemmy >= 0.19.4 attachment = request_json['object']['attachment'][0]
if 'name' in request_json['object']['attachment'][0]: post.url = attachment['url'] # PixelFed, PieFed, Lemmy >= 0.19.4
alt_text = request_json['object']['attachment'][0]['name'] alt_text = attachment.get("name")
file_path = attachment.get("file_path")
if 'attachment' in request_json['object'] and isinstance(request_json['object']['attachment'], dict): # a.gup.pe (Mastodon) if 'attachment' in request_json['object'] and isinstance(request_json['object']['attachment'], dict): # a.gup.pe (Mastodon)
alt_text = None alt_text = None
@ -1272,6 +1273,8 @@ class Post(db.Model):
image = File(source_url=post.url) image = File(source_url=post.url)
if alt_text: if alt_text:
image.alt_text = alt_text image.alt_text = alt_text
if file_path:
image.file_path = file_path
db.session.add(image) db.session.add(image)
post.image = image post.image = image
elif is_video_url(post.url): # youtube is detected later elif is_video_url(post.url): # youtube is detected later

View file

@ -32,7 +32,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message, recently_upvoted_posts, \ blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message, recently_upvoted_posts, \
recently_downvoted_posts, recently_upvoted_post_replies, recently_downvoted_post_replies, reply_is_stupid, \ recently_downvoted_posts, recently_upvoted_post_replies, recently_downvoted_post_replies, reply_is_stupid, \
languages_for_form, menu_topics, add_to_modlog, blocked_communities, piefed_markdown_to_lemmy_markdown, \ languages_for_form, menu_topics, add_to_modlog, blocked_communities, piefed_markdown_to_lemmy_markdown, \
permission_required, blocked_users, get_request permission_required, blocked_users, get_request, is_local_image_url, is_video_url
def show_post(post_id: int): def show_post(post_id: int):
@ -730,14 +730,23 @@ def post_reply_options(post_id: int, comment_id: int):
@login_required @login_required
def post_edit(post_id: int): def post_edit(post_id: int):
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)
post_type = post.type
if post.type == POST_TYPE_ARTICLE: if post.type == POST_TYPE_ARTICLE:
form = CreateDiscussionForm() form = CreateDiscussionForm()
elif post.type == POST_TYPE_LINK: elif post.type == POST_TYPE_LINK:
form = CreateLinkForm() form = CreateLinkForm()
elif post.type == POST_TYPE_IMAGE: elif post.type == POST_TYPE_IMAGE:
form = EditImageForm() if post.image and post.image.source_url and is_local_image_url(post.image.source_url):
form = EditImageForm()
else:
form = CreateLinkForm()
post_type = POST_TYPE_LINK
elif post.type == POST_TYPE_VIDEO: elif post.type == POST_TYPE_VIDEO:
form = CreateVideoForm() if is_video_url(post.url):
form = CreateVideoForm()
else:
form = CreateLinkForm()
post_type = POST_TYPE_LINK
elif post.type == POST_TYPE_POLL: elif post.type == POST_TYPE_POLL:
form = CreatePollForm() form = CreatePollForm()
poll = Poll.query.filter_by(post_id=post_id).first() poll = Poll.query.filter_by(post_id=post_id).first()
@ -769,7 +778,7 @@ def post_edit(post_id: int):
form.language_id.choices = languages_for_form() form.language_id.choices = languages_for_form()
if form.validate_on_submit(): if form.validate_on_submit():
save_post(form, post, post.type) save_post(form, post, post_type)
post.community.last_active = utcnow() post.community.last_active = utcnow()
post.edited_at = utcnow() post.edited_at = utcnow()
@ -812,14 +821,23 @@ def post_edit(post_id: int):
form.sticky.data = post.sticky form.sticky.data = post.sticky
form.language_id.data = post.language_id form.language_id.data = post.language_id
form.tags.data = tags_to_string(post) form.tags.data = tags_to_string(post)
if post.type == POST_TYPE_LINK: if post_type == POST_TYPE_LINK:
form.link_url.data = post.url form.link_url.data = post.url
elif post.type == POST_TYPE_IMAGE: elif post_type == POST_TYPE_IMAGE:
# existing_image = True # existing_image = True
form.image_alt_text.data = post.image.alt_text form.image_alt_text.data = post.image.alt_text
elif post.type == POST_TYPE_VIDEO: path = post.image.file_path
# This is fallback for existing entries
if not path:
path = "app/" + post.image.source_url.replace(
f"https://{current_app.config['SERVER_NAME']}/", ""
)
with open(path, "rb")as file:
form.image_file.data = file.read()
elif post_type == POST_TYPE_VIDEO:
form.video_url.data = post.url form.video_url.data = post.url
elif post.type == POST_TYPE_POLL: elif post_type == POST_TYPE_POLL:
poll = Poll.query.filter_by(post_id=post.id).first() poll = Poll.query.filter_by(post_id=post.id).first()
form.mode.data = poll.mode form.mode.data = poll.mode
form.local_only.data = poll.local_only form.local_only.data = poll.local_only
@ -832,7 +850,7 @@ def post_edit(post_id: int):
if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True} form.sticky.render_kw = {'disabled': True}
return render_template('post/post_edit.html', title=_('Edit post'), form=form, return render_template('post/post_edit.html', title=_('Edit post'), form=form,
post_type=post.type, community=post.community, post=post, post_type=post_type, community=post.community, post=post,
markdown_editor=current_user.markdown_editor, mods=mod_list, markdown_editor=current_user.markdown_editor, mods=mod_list,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()),

View file

@ -32,9 +32,8 @@
</a> </a>
{% endif -%} {% endif -%}
</div> </div>
{% else %}
{{ render_field(form.image_file) }}
{% endif %} {% endif %}
{{ render_field(form.image_file) }}
{{ render_field(form.image_alt_text) }} {{ render_field(form.image_alt_text) }}
<small class="field_hint">{{ _('Describe the image, to help visually impaired people.') }}</small> <small class="field_hint">{{ _('Describe the image, to help visually impaired people.') }}</small>
{% elif post_type == POST_TYPE_VIDEO %} {% elif post_type == POST_TYPE_VIDEO %}

View file

@ -22,6 +22,7 @@ import jwt
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning) warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
import os import os
from furl import furl
from flask import current_app, json, redirect, url_for, request, make_response, Response, g, flash from flask import current_app, json, redirect, url_for, request, make_response, Response, g, flash
from flask_babel import _ from flask_babel import _
from flask_login import current_user, logout_user from flask_login import current_user, logout_user
@ -195,6 +196,13 @@ def is_image_url(url):
return any(path.endswith(extension) for extension in common_image_extensions) return any(path.endswith(extension) for extension in common_image_extensions)
def is_local_image_url(url):
if not is_image_url(url):
return False
f = furl(url)
return f.host in ["127.0.0.1", current_app.config["SERVER_NAME"]]
def is_video_url(url: str) -> bool: def is_video_url(url: str) -> bool:
common_video_extensions = ['.mp4', '.webm'] common_video_extensions = ['.mp4', '.webm']
mime_type = mime_type_using_head(url) mime_type = mime_type_using_head(url)

View file

@ -32,3 +32,4 @@ Werkzeug==2.3.3
pytesseract==0.3.10 pytesseract==0.3.10
sentry-sdk==1.40.6 sentry-sdk==1.40.6
python-slugify==8.0.4 python-slugify==8.0.4
furl==2.1.3