diff --git a/app/api/alpha/routes.py b/app/api/alpha/routes.py index 22eadfcf..5de30055 100644 --- a/app/api/alpha/routes.py +++ b/app/api/alpha/routes.py @@ -1,7 +1,7 @@ from app.api.alpha import bp from app.api.alpha.utils import get_site, post_site_block, \ get_search, \ - get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, post_post, \ + get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, post_post, put_post, \ get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_delete, post_reply_report, \ get_community_list, get_community, post_community_follow, post_community_block, \ get_user, post_user_block @@ -171,6 +171,18 @@ def post_alpha_post(): return jsonify({"error": str(ex)}), 400 +@bp.route('/api/alpha/post', methods=['PUT']) +def put_alpha_post(): + if not enable_api(): + return jsonify({'error': 'alpha api is not enabled'}) + try: + auth = request.headers.get('Authorization') + data = request.get_json(force=True) or {} + return jsonify(put_post(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + # Reply @bp.route('/api/alpha/comment/list', methods=['GET']) def get_alpha_comment_list(): @@ -333,7 +345,6 @@ def alpha_community(): return jsonify({"error": "not_yet_implemented"}), 400 # Post - not yet implemented -@bp.route('/api/alpha/post', methods=['PUT']) @bp.route('/api/alpha/post/delete', methods=['POST']) @bp.route('/api/alpha/post/remove', methods=['POST']) @bp.route('/api/alpha/post/lock', methods=['POST']) diff --git a/app/api/alpha/utils/__init__.py b/app/api/alpha/utils/__init__.py index e43dd700..a770a08c 100644 --- a/app/api/alpha/utils/__init__.py +++ b/app/api/alpha/utils/__init__.py @@ -1,6 +1,6 @@ from app.api.alpha.utils.site import get_site, post_site_block from app.api.alpha.utils.misc import get_search -from app.api.alpha.utils.post import get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, post_post +from app.api.alpha.utils.post import get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, post_post, put_post from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_delete, post_reply_report from app.api.alpha.utils.community import get_community, get_community_list, post_community_follow, post_community_block from app.api.alpha.utils.user import get_user, post_user_block diff --git a/app/api/alpha/utils/post.py b/app/api/alpha/utils/post.py index fd141057..a69f368e 100644 --- a/app/api/alpha/utils/post.py +++ b/app/api/alpha/utils/post.py @@ -1,9 +1,10 @@ from app import cache from app.api.alpha.views import post_view from app.api.alpha.utils.validators import required, integer_expected, boolean_expected, string_expected +from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO from app.models import Post, Community, CommunityMember, utcnow -from app.shared.post import vote_for_post, bookmark_the_post, remove_the_bookmark_from_post, toggle_post_notification, make_post -from app.utils import authorise_api_user, blocked_users, blocked_communities, blocked_instances, community_ids_from_instances, is_image_url +from app.shared.post import vote_for_post, bookmark_the_post, remove_the_bookmark_from_post, toggle_post_notification, make_post, edit_post +from app.utils import authorise_api_user, blocked_users, blocked_communities, blocked_instances, community_ids_from_instances, is_image_url, is_video_url from datetime import timedelta from sqlalchemy import desc @@ -154,7 +155,7 @@ def post_post(auth, data): required(['title', 'community_id'], data) integer_expected(['language_id'], data) boolean_expected(['nsfw'], data) - string_expected(['string', 'body'], data) + string_expected(['title', 'body', 'url'], data) title = data['title'] community_id = data['community_id'] @@ -169,6 +170,8 @@ def post_post(auth, data): type = 'discussion' elif is_image_url(url): type = 'image' + elif is_video_url(url): + type = 'video' else: type = 'link' @@ -180,3 +183,33 @@ def post_post(auth, data): return post_json +def put_post(auth, data): + required(['post_id'], data) + integer_expected(['language_id'], data) + boolean_expected(['nsfw'], data) + string_expected(['title', 'body', 'url'], data) + + post_id = data['post_id'] + title = data['title'] + body = data['body'] if 'body' in data else '' + url = data['url'] if 'url' in data else None + nsfw = data['nsfw'] if 'nsfw' in data else False + language_id = data['language_id'] if 'language_id' in data else 2 # FIXME: use site language + if language_id < 2: + language_id = 2 + + if not url: + type = POST_TYPE_ARTICLE + elif is_image_url(url): + type = POST_TYPE_IMAGE + elif is_video_url(url): + type = POST_TYPE_VIDEO + else: + type = POST_TYPE_LINK + + input = {'title': title, 'body': body, 'url': url, 'nsfw': nsfw, 'language_id': language_id, 'notify_author': True} + post = Post.query.filter_by(id=post_id).one() + user_id, post = edit_post(input, post, type, SRC_API, auth) + + post_json = post_view(post=post, variant=4, user_id=user_id) + return post_json diff --git a/app/shared/post.py b/app/shared/post.py index 31e39d53..73430a6a 100644 --- a/app/shared/post.py +++ b/app/shared/post.py @@ -1,17 +1,21 @@ from app import db from app.constants import * -from app.community.util import tags_from_string -from app.models import Language, NotificationSubscription, Post, PostBookmark +from app.community.util import tags_from_string, tags_from_string_old, end_poll_date +from app.models import File, Language, NotificationSubscription, Poll, PollChoice, Post, PostBookmark, utcnow from app.shared.tasks import task_selector -from app.utils import render_template, authorise_api_user, shorten_string, gibberish, ensure_directory_exists +from app.utils import render_template, authorise_api_user, shorten_string, gibberish, ensure_directory_exists, \ + piefed_markdown_to_lemmy_markdown, markdown_to_html, remove_tracking_from_link, domain_from_url, \ + opengraph_parse, url_to_thumbnail_file, blocked_phrases -from flask import abort, flash, redirect, request, url_for, current_app +from flask import abort, flash, redirect, request, url_for, current_app, g from flask_babel import _ from flask_login import current_user from pillow_heif import register_heif_opener from PIL import Image, ImageOps +from sqlalchemy import text + import os # would be in app/constants.py @@ -240,3 +244,258 @@ def make_post(input, community, type, src, auth=None, uploaded_file=None): return post +def edit_post(input, post, type, src, auth=None, uploaded_file=None): + if src == SRC_API: + user = authorise_api_user(auth, return_type='model') + title = input['title'] + body = input['body'] + url = input['url'] + nsfw = input['nsfw'] + notify_author = input['notify_author'] + language_id = input['language_id'] + tags = [] + else: + user = current_user + title = input.title.data + body = input.body.data + url = input.link_url.data + nsfw = input.nsfw.data + notify_author = input.notify_author.data + language_id = input.language_id.data + tags = tags_from_string(input.tags.data) + + post.indexable = user.indexable + post.sticky = False if src == SRC_API else input.sticky.data + post.nsfw = nsfw + post.nsfl = False if src == SRC_API else input.nsfl.data + post.notify_author = notify_author + post.language_id = language_id + user.language_id = language_id + post.title = title.strip() + post.body = piefed_markdown_to_lemmy_markdown(body) + post.body_html = markdown_to_html(post.body) + + allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic', '.mpo', '.avif', '.svg'] + + if not type or type == POST_TYPE_ARTICLE: + post.type = POST_TYPE_ARTICLE + elif type == POST_TYPE_LINK: + url_changed = post.id is None or url != post.url # post.id ? + post.url = remove_tracking_from_link(url.strip()) + post.type = POST_TYPE_LINK + domain = domain_from_url(url) + domain.post_count += 1 + post.domain = domain + + if url_changed: + if post.image_id: + remove_file = File.query.get(post.image_id) + remove_file.delete_from_disk() + post.image_id = None + + if post.url.endswith('.mp4') or post.url.endswith('.webm'): + post.type = POST_TYPE_VIDEO + file = File(source_url=url) # make_image_sizes() will take care of turning this into a still image + post.image = file + db.session.add(file) + else: + unused, file_extension = os.path.splitext(url) + # this url is a link to an image - turn it into a image post + if file_extension.lower() in allowed_extensions: + file = File(source_url=url) + post.image = file + db.session.add(file) + post.type = POST_TYPE_IMAGE + else: + # check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag + if not post.type == POST_TYPE_VIDEO: + tn_url = url + if tn_url[:32] == 'https://www.youtube.com/watch?v=': + tn_url = 'https://youtu.be/' + tn_url[32:43] # better chance of thumbnail from youtu.be than youtube.com + opengraph = opengraph_parse(tn_url) + if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''): + filename = opengraph.get('og:image') or opengraph.get('og:image:url') + if not filename.startswith('/'): + file = url_to_thumbnail_file(filename) + if file: + file.alt_text = shorten_string(opengraph.get('og:title'), 295) + post.image = file + db.session.add(file) + + elif type == POST_TYPE_IMAGE: + post.type = POST_TYPE_IMAGE + # If we are uploading new file in the place of existing one just remove the old one + if post.image_id is not None and src == SRC_WEB: + post.image.delete_from_disk() + image_id = post.image_id + post.image_id = None + db.session.add(post) + db.session.commit() + File.query.filter_by(id=image_id).delete() + + if uploaded_file and uploaded_file.filename != '' and src == SRC_WEB: + if post.image_id: + remove_file = File.query.get(post.image_id) + remove_file.delete_from_disk() + post.image_id = None + + # check if this is an allowed type of file + file_ext = os.path.splitext(uploaded_file.filename)[1] + if file_ext.lower() not in allowed_extensions: + abort(400) + new_filename = gibberish(15) + + # set up the storage directory + directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4] + ensure_directory_exists(directory) + + # save the file + 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) + + if file_ext.lower() == '.heic': + register_heif_opener() + + Image.MAX_IMAGE_PIXELS = 89478485 + + # resize if necessary + img = Image.open(final_place) + if '.' + img.format.lower() in allowed_extensions: + img = ImageOps.exif_transpose(img) + + # limit full sized version to 2000px + img_width = img.width + img_height = img.height + img.thumbnail((2000, 2000)) + img.save(final_place) + + # medium sized version + img.thumbnail((512, 512)) + img.save(final_place_medium, format="WebP", quality=93) + + # 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 + + alt_text = input.image_alt_text.data if input.image_alt_text.data else title + + 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: + url = url.strip() + url_changed = post.id is None or url != post.url + post.url = remove_tracking_from_link(url) + post.type = POST_TYPE_VIDEO + domain = domain_from_url(url) + domain.post_count += 1 + post.domain = domain + + if url_changed: + if post.image_id: + remove_file = File.query.get(post.image_id) + remove_file.delete_from_disk() + post.image_id = None + if url.endswith('.mp4') or url('.webm'): + file = File(source_url=url) # make_image_sizes() will take care of turning this into a still image + post.image = file + db.session.add(file) + else: + # check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag + tn_url = url + if tn_url[:32] == 'https://www.youtube.com/watch?v=': + tn_url = 'https://youtu.be/' + tn_url[32:43] # better chance of thumbnail from youtu.be than youtube.com + opengraph = opengraph_parse(tn_url) + if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''): + filename = opengraph.get('og:image') or opengraph.get('og:image:url') + if not filename.startswith('/'): + file = url_to_thumbnail_file(filename) + if file: + file.alt_text = shorten_string(opengraph.get('og:title'), 295) + post.image = file + db.session.add(file) + + elif type == POST_TYPE_POLL: + post.body = title + '\n' + body if title not in body else body + post.body_html = markdown_to_html(post.body) + post.type = POST_TYPE_POLL + else: + raise Exception('invalid post type') + + if post.id is None: + if user.reputation > 100: + post.up_votes = 1 + post.score = 1 + if user.reputation < -100: + post.score = -1 + post.ranking = post.post_ranking(post.score, utcnow()) + + # Filter by phrase + blocked_phrases_list = blocked_phrases() + for blocked_phrase in blocked_phrases_list: + if blocked_phrase in post.title: + abort(401) + return + if post.body: + for blocked_phrase in blocked_phrases_list: + if blocked_phrase in post.body: + abort(401) + return + + db.session.add(post) + else: + db.session.execute(text('DELETE FROM "post_tag" WHERE post_id = :post_id'), {'post_id': post.id}) + post.tags = tags + db.session.commit() + + # Save poll choices. NB this will delete all votes whenever a poll is edited. Partially because it's easier to code but also to stop malicious alterations to polls after people have already voted + if type == POST_TYPE_POLL: + db.session.execute(text('DELETE FROM "poll_choice_vote" WHERE post_id = :post_id'), {'post_id': post.id}) + db.session.execute(text('DELETE FROM "poll_choice" WHERE post_id = :post_id'), {'post_id': post.id}) + for i in range(1, 10): + choice_data = getattr(input, f"choice_{i}").data.strip() + if choice_data != '': + db.session.add(PollChoice(post_id=post.id, choice_text=choice_data, sort_order=i)) + + poll = Poll.query.filter_by(post_id=post.id).first() + if poll is None: + poll = Poll(post_id=post.id) + db.session.add(poll) + poll.mode = input.mode.data + if input.finish_in: + poll.end_poll = end_poll_date(input.finish_in.data) + poll.local_only = input.local_only.data + poll.latest_vote = None + db.session.commit() + + # Notify author about replies + # Remove any subscription that currently exists + existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post.id, + NotificationSubscription.user_id == user.id, + NotificationSubscription.type == NOTIF_POST).first() + if existing_notification: + db.session.delete(existing_notification) + + # Add subscription if necessary + if notify_author: + new_notification = NotificationSubscription(name=post.title, user_id=user.id, entity_id=post.id, + type=NOTIF_POST) + db.session.add(new_notification) + + g.site.last_active = utcnow() + db.session.commit() + + task_selector('edit_post', user_id=user.id, post_id=post.id) + + if src == SRC_API: + return user.id, post