From 1e06b42099b73f5a28b9d1190851549d1371ac16 Mon Sep 17 00:00:00 2001 From: freamon Date: Fri, 20 Sep 2024 16:06:08 +0000 Subject: [PATCH] Add inital routes for API (with minimal changes to main codebase) --- app/__init__.py | 3 + app/api/alpha/__init__.py | 5 + app/api/alpha/routes.py | 262 +++++++++++++++++++++++++ app/api/alpha/utils/__init__.py | 7 + app/api/alpha/utils/community.py | 62 ++++++ app/api/alpha/utils/post.py | 134 +++++++++++++ app/api/alpha/utils/reply.py | 96 +++++++++ app/api/alpha/utils/site.py | 95 +++++++++ app/api/alpha/utils/user.py | 36 ++++ app/api/alpha/utils/validators.py | 45 +++++ app/api/alpha/views.py | 312 ++++++++++++++++++++++++++++++ app/models.py | 7 + app/shared/auth.py | 111 +++++++++++ app/shared/post.py | 94 +++++++++ app/shared/reply.py | 97 ++++++++++ app/utils.py | 21 ++ 16 files changed, 1387 insertions(+) create mode 100644 app/api/alpha/__init__.py create mode 100644 app/api/alpha/routes.py create mode 100644 app/api/alpha/utils/__init__.py create mode 100644 app/api/alpha/utils/community.py create mode 100644 app/api/alpha/utils/post.py create mode 100644 app/api/alpha/utils/reply.py create mode 100644 app/api/alpha/utils/site.py create mode 100644 app/api/alpha/utils/user.py create mode 100644 app/api/alpha/utils/validators.py create mode 100644 app/api/alpha/views.py create mode 100644 app/shared/auth.py create mode 100644 app/shared/post.py create mode 100644 app/shared/reply.py diff --git a/app/__init__.py b/app/__init__.py index 45b2c6fe..1d23d962 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -108,6 +108,9 @@ def create_app(config_class=Config): from app.dev import bp as dev_bp app.register_blueprint(dev_bp) + from app.api.alpha import bp as app_api_bp + app.register_blueprint(app_api_bp) + # send error reports via email if app.config['MAIL_SERVER'] and app.config['MAIL_ERRORS']: auth = None diff --git a/app/api/alpha/__init__.py b/app/api/alpha/__init__.py new file mode 100644 index 00000000..af9b384f --- /dev/null +++ b/app/api/alpha/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('api_alpha', __name__) + +from app.api.alpha import routes diff --git a/app/api/alpha/routes.py b/app/api/alpha/routes.py new file mode 100644 index 00000000..7d1cfd09 --- /dev/null +++ b/app/api/alpha/routes.py @@ -0,0 +1,262 @@ +from app.api.alpha import bp +from app.api.alpha.utils import get_site, \ + get_post_list, get_post, post_post_like, \ + get_reply_list, post_reply_like, \ + get_community_list, get_community, \ + get_user +from app.shared.auth import log_user_in + +from flask import current_app, jsonify, request + + +# Site +@bp.route('/api/alpha/site', methods=['GET']) +def get_alpha_site(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + return jsonify(get_site(auth)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +# Community +@bp.route('/api/alpha/community', methods=['GET']) +def get_alpha_community(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = None + data = request.args.to_dict() or None + return jsonify(get_community(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +@bp.route('/api/alpha/community/list', methods=['GET']) +def get_alpha_community_list(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + data = request.args.to_dict() or None + return jsonify(get_community_list(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +# Post +@bp.route('/api/alpha/post/list', methods=['GET']) +def get_alpha_post_list(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + data = request.args.to_dict() or None + return jsonify(get_post_list(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +@bp.route('/api/alpha/post', methods=['GET']) +def get_alpha_post(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + data = request.args.to_dict() or None + return jsonify(get_post(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +@bp.route('/api/alpha/post/like', methods=['POST']) +def post_alpha_post_like(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + data = request.get_json(force=True) or {} + return jsonify(post_post_like(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(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + data = request.args.to_dict() or None + return jsonify(get_reply_list(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +@bp.route('/api/alpha/comment/like', methods=['POST']) +def post_alpha_comment_like(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + data = request.get_json(force=True) or {} + return jsonify(post_reply_like(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +# User +@bp.route('/api/alpha/user', methods=['GET']) +def get_alpha_user(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + data = request.args.to_dict() or None + return jsonify(get_user(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +@bp.route('/api/alpha/user/login', methods=['POST']) +def post_alpha_login(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + SRC_API = 3 # would be in app.constants + data = request.get_json(force=True) or {} + return jsonify(log_user_in(data, SRC_API)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +# Not yet implemented. Copied from lemmy's V3 api, so some aren't needed, and some need changing + +# Site - not yet implemented +@bp.route('/api/alpha/site', methods=['POST']) +@bp.route('/api/alpha/site', methods=['PUT']) +@bp.route('/api/alpha/site/block', methods=['POST']) +def alpha_site(): + return jsonify({"error": "not_yet_implemented"}), 400 + +# Miscellaneous - not yet implemented +@bp.route('/api/alpha/modlog', methods=['GET']) +@bp.route('/api/alpha/search', methods=['GET']) +@bp.route('/api/alpha/resolve_object', methods=['GET']) +@bp.route('/api/alpha/federated_instances', methods=['GET']) +def alpha_miscellaneous(): + return jsonify({"error": "not_yet_implemented"}), 400 + +# Community - not yet implemented +@bp.route('/api/alpha/community', methods=['POST']) +@bp.route('/api/alpha/community', methods=['PUT']) +@bp.route('/api/alpha/community/hide', methods=['PUT']) +@bp.route('/api/alpha/community/follow', methods=['POST']) +@bp.route('/api/alpha/community/block', methods=['POST']) +@bp.route('/api/alpha/community/delete', methods=['POST']) +@bp.route('/api/alpha/community/remove', methods=['POST']) +@bp.route('/api/alpha/community/transfer', methods=['POST']) +@bp.route('/api/alpha/community/ban_user', methods=['POST']) +@bp.route('/api/alpha/community/mod', methods=['POST']) +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', methods=['POST']) +@bp.route('/api/alpha/post/delete', methods=['POST']) +@bp.route('/api/alpha/post/remove', methods=['POST']) +@bp.route('/api/alpha/post/lock', methods=['POST']) +@bp.route('/api/alpha/post/feature', methods=['POST']) +@bp.route('/api/alpha/post/like', methods=['POST']) +@bp.route('/api/alpha/post/save', methods=['PUT']) +@bp.route('/api/alpha/post/report', methods=['POST']) +@bp.route('/api/alpha/post/report/resolve', methods=['PUT']) +@bp.route('/api/alpha/post/report/list', methods=['GET']) +@bp.route('/api/alpha/post/site_metadata', methods=['GET']) +def alpha_post(): + return jsonify({"error": "not_yet_implemented"}), 400 + +# Reply - not yet implemented +@bp.route('/api/alpha/comment', methods=['GET']) +@bp.route('/api/alpha/comment', methods=['PUT']) +@bp.route('/api/alpha/comment', methods=['POST']) +@bp.route('/api/alpha/comment/delete', methods=['POST']) +@bp.route('/api/alpha/comment/remove', methods=['POST']) +@bp.route('/api/alpha/comment/mark_as_read', methods=['POST']) +@bp.route('/api/alpha/comment/distinguish', methods=['POST']) +@bp.route('/api/alpha/comment/save', methods=['PUT']) +@bp.route('/api/alpha/comment/report', methods=['POST']) +@bp.route('/api/alpha/comment/report/resolve', methods=['PUT']) +@bp.route('/api/alpha/comment/report/list', methods=['GET']) +def alpha_reply(): + return jsonify({"error": "not_yet_implemented"}), 400 + +# Chat - not yet implemented +@bp.route('/api/alpha/private_message/list', methods=['GET']) +@bp.route('/api/alpha/private_message', methods=['PUT']) +@bp.route('/api/alpha/private_message', methods=['POST']) +@bp.route('/api/alpha/private_message/delete', methods=['POST']) +@bp.route('/api/alpha/private_message/mark_as_read', methods=['POST']) +@bp.route('/api/alpha/private_message/report', methods=['POST']) +@bp.route('/api/alpha/private_message/report/resolve', methods=['PUT']) +@bp.route('/api/alpha/private_message/report/list', methods=['GET']) +def alpha_chat(): + return jsonify({"error": "not_yet_implemented"}), 400 + +# User - not yet implemented +@bp.route('/api/alpha/user/register', methods=['POST']) +@bp.route('/api/alpha/user/get_captcha', methods=['GET']) +@bp.route('/api/alpha/user/mention', methods=['GET']) +@bp.route('/api/alpha/user/mention/mark_as_read', methods=['POST']) +@bp.route('/api/alpha/user/replies', methods=['GET']) +@bp.route('/api/alpha/user/ban', methods=['POST']) +@bp.route('/api/alpha/user/banned', methods=['GET']) +@bp.route('/api/alpha/user/block', methods=['POST']) +@bp.route('/api/alpha/user/delete_account', methods=['POST']) +@bp.route('/api/alpha/user/password_reset', methods=['POST']) +@bp.route('/api/alpha/user/password_change', methods=['POST']) +@bp.route('/api/alpha/user/mark_all_as_read', methods=['POST']) +@bp.route('/api/alpha/user/save_user_settings', methods=['PUT']) +@bp.route('/api/alpha/user/change_password', methods=['PUT']) +@bp.route('/api/alpha/user/repost_count', methods=['GET']) +@bp.route('/api/alpha/user/unread_count', methods=['GET']) +@bp.route('/api/alpha/user/verify_email', methods=['POST']) +@bp.route('/api/alpha/user/leave_admin', methods=['POST']) +@bp.route('/api/alpha/user/totp/generate', methods=['POST']) +@bp.route('/api/alpha/user/totp/update', methods=['POST']) +@bp.route('/api/alpha/user/export_settings', methods=['GET']) +@bp.route('/api/alpha/user/import_settings', methods=['POST']) +@bp.route('/api/alpha/user/list_logins', methods=['GET']) +@bp.route('/api/alpha/user/validate_auth', methods=['GET']) +@bp.route('/api/alpha/user/logout', methods=['POST']) +def alpha_user(): + return jsonify({"error": "not_yet_implemented"}), 400 + +# Admin - not yet implemented +@bp.route('/api/alpha/admin/add', methods=['POST']) +@bp.route('/api/alpha/admin/registration_application/count', methods=['GET']) +@bp.route('/api/alpha/admin/registration_application/list', methods=['GET']) +@bp.route('/api/alpha/admin/registration_application/approve', methods=['PUT']) +@bp.route('/api/alpha/admin/purge/person', methods=['POST']) +@bp.route('/api/alpha/admin/purge/community', methods=['POST']) +@bp.route('/api/alpha/admin/purge/post', methods=['POST']) +@bp.route('/api/alpha/admin/purge/comment', methods=['POST']) +@bp.route('/api/alpha/post/like/list', methods=['GET']) +@bp.route('/api/alpha/comment/like/list', methods=['GET']) +def alpha_admin(): + return jsonify({"error": "not_yet_implemented"}), 400 + +# CustomEmoji - not yet implemented +@bp.route('/api/alpha/custom_emoji', methods=['PUT']) +@bp.route('/api/alpha/custom_emoji', methods=['POST']) +@bp.route('/api/alpha/custom_emoji/delete', methods=['POST']) +def alpha_emoji(): + return jsonify({"error": "not_yet_implemented"}), 400 + + + + diff --git a/app/api/alpha/utils/__init__.py b/app/api/alpha/utils/__init__.py new file mode 100644 index 00000000..c988562d --- /dev/null +++ b/app/api/alpha/utils/__init__.py @@ -0,0 +1,7 @@ +from app.api.alpha.utils.site import get_site +from app.api.alpha.utils.post import get_post_list, get_post, post_post_like +from app.api.alpha.utils.reply import get_reply_list, post_reply_like +from app.api.alpha.utils.community import get_community, get_community_list +from app.api.alpha.utils.user import get_user + + diff --git a/app/api/alpha/utils/community.py b/app/api/alpha/utils/community.py new file mode 100644 index 00000000..4300a27c --- /dev/null +++ b/app/api/alpha/utils/community.py @@ -0,0 +1,62 @@ +from app import cache +from app.api.alpha.views import community_view +from app.utils import authorise_api_user +from app.models import Community, CommunityMember +from app.utils import communities_banned_from + + +@cache.memoize(timeout=3) +def cached_community_list(type, user_id): + if type == 'Subscribed' and user_id is not None: + communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == user_id) + banned_from = communities_banned_from(user_id) + if banned_from: + communities = communities.filter(Community.id.not_in(banned_from)) + else: + communities = Community.query.filter_by(banned=False) + + return communities.all() + + +def get_community_list(auth, data): + type = data['type_'] if data and 'type_' in data else "All" + page = int(data['page']) if data and 'page' in data else 1 + limit = int(data['limit']) if data and 'limit' in data else 10 + + if auth: + try: + user_id = authorise_api_user(auth) + except Exception as e: + raise e + else: + user_id = None + + communities = cached_community_list(type, user_id) + + start = (page - 1) * limit + end = start + limit + communities = communities[start:end] + + communitylist = [] + for community in communities: + communitylist.append(community_view(community=community, variant=2, stub=True, user_id=user_id)) + list_json = { + "communities": communitylist + } + + return list_json + + +def get_community(auth, data): + if not data or ('id' not in data and 'name' not in data): + raise Exception('missing_parameters') + if 'id' in data: + community = int(data['id']) + elif 'name' in data: + community = data['name'] + + try: + community_json = community_view(community=community, variant=3) + return community_json + except: + raise diff --git a/app/api/alpha/utils/post.py b/app/api/alpha/utils/post.py new file mode 100644 index 00000000..0ca622d5 --- /dev/null +++ b/app/api/alpha/utils/post.py @@ -0,0 +1,134 @@ +from app import cache +from app.api.alpha.views import post_view +from app.api.alpha.utils.validators import required, integer_expected +from app.models import Post, Community, CommunityMember, utcnow +from app.shared.post import vote_for_post +from app.utils import authorise_api_user + +from datetime import timedelta +from sqlalchemy import desc + + +@cache.memoize(timeout=3) +def cached_post_list(type, sort, user_id, community_id, community_name, person_id): + if type == "All": + if community_name: + posts = Post.query.filter_by(deleted=False).join(Community, Community.id == Post.community_id).filter_by(show_all=True, name=community_name) + elif community_id: + posts = Post.query.filter_by(deleted=False).join(Community, Community.id == Post.community_id).filter_by(show_all=True, id=community_id) + elif person_id: + posts = Post.query.filter_by(deleted=False, user_id=person_id) + else: + posts = Post.query.filter_by(deleted=False).join(Community, Community.id == Post.community_id).filter_by(show_all=True) + elif type == "Local": + posts = Post.query.filter_by(deleted=False).join(Community, Community.id == Post.community_id).filter_by(ap_id=None) + elif type == "Subscribed" and user_id is not None: + posts = Post.query.filter_by(deleted=False).join(CommunityMember, Post.community_id == CommunityMember.community_id).filter_by(is_banned=False, user_id=user_id) + else: + posts = Post.query.filter_by(deleted=False) + + if sort == "Hot": + posts = posts.order_by(desc(Post.ranking)).order_by(desc(Post.posted_at)) + elif sort == "TopDay": + posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.up_votes - Post.down_votes)) + elif sort == "New": + posts = posts.order_by(desc(Post.posted_at)) + elif sort == "Active": + posts = posts.order_by(desc(Post.last_active)) + + return posts.all() + + +def get_post_list(auth, data, user_id=None): + type = data['type_'] if data and 'type_' in data else "All" + sort = data['sort'] if data and 'sort' in data else "Hot" + page = int(data['page']) if data and 'page' in data else 1 + limit = int(data['limit']) if data and 'limit' in data else 10 + + if auth: + try: + user_id = authorise_api_user(auth) + except Exception as e: + raise e + + # user_id: the logged in user + # person_id: the author of the posts being requested + + community_id = int(data['community_id']) if data and 'community_id' in data else None + community_name = data['community_name'] if data and 'community_name' in data else None + person_id = int(data['person_id']) if data and 'person_id' in data else None + + posts = cached_post_list(type, sort, user_id, community_id, community_name, person_id) + + start = (page - 1) * limit + end = start + limit + posts = posts[start:end] + + postlist = [] + for post in posts: + try: + postlist.append(post_view(post=post, variant=2, stub=True, user_id=user_id)) + except: + continue + list_json = { + "posts": postlist + } + + return list_json + + +def get_post(auth, data): + if not data or 'id' not in data: + raise Exception('missing_parameters') + + id = int(data['id']) + + if auth: + try: + user_id = authorise_api_user(auth) + except Exception as e: + raise e + else: + user_id = None + + post_json = post_view(post=id, variant=3, user_id=user_id) + if post_json: + return post_json + else: + raise Exception('post_not_found') + + +# would be in app/constants.py +SRC_API = 3 + +def post_post_like(auth, data): + try: + required(['post_id', 'score'], data) + integer_expected(['post_id', 'score'], data) + except: + raise + + post_id = data['post_id'] + score = data['score'] + if score == 1: + direction = 'upvote' + elif score == -1: + direction = 'downvote' + else: + score = 0 + direction = 'reversal' + + try: + user_id = vote_for_post(post_id, direction, SRC_API, auth) + cache.delete_memoized(cached_post_list) + post_json = post_view(post=post_id, variant=4, user_id=user_id, my_vote=score) + return post_json + except: + raise + + + + + + + diff --git a/app/api/alpha/utils/reply.py b/app/api/alpha/utils/reply.py new file mode 100644 index 00000000..a61dde5f --- /dev/null +++ b/app/api/alpha/utils/reply.py @@ -0,0 +1,96 @@ +from app import cache +from app.utils import authorise_api_user +from app.api.alpha.utils.validators import required, integer_expected +from app.api.alpha.views import reply_view +from app.models import PostReply +from app.shared.reply import vote_for_reply + +from sqlalchemy import desc + + +@cache.memoize(timeout=3) +def cached_reply_list(post_id, person_id, sort, max_depth): + if post_id: + replies = PostReply.query.filter(PostReply.deleted == False, PostReply.post_id == post_id, PostReply.depth <= max_depth) + if person_id: + replies = PostReply.query.filter_by(deleted=False, user_id=person_id) + + if sort == "Hot": + replies = replies.order_by(desc(PostReply.ranking)).order_by(desc(PostReply.posted_at)) + elif sort == "Top": + replies = replies.order_by(desc(PostReply.up_votes - PostReply.down_votes)) + elif sort == "New": + replies = replies.order_by(desc(PostReply.posted_at)) + + return replies.all() + + +def get_reply_list(auth, data, user_id=None): + sort = data['sort'] if data and 'sort' in data else "New" + max_depth = data['max_depth'] if data and 'max_depth' in data else 8 + page = int(data['page']) if data and 'page' in data else 1 + limit = int(data['limit']) if data and 'limit' in data else 10 + post_id = data['post_id'] if data and 'post_id' in data else None + person_id = data['person_id'] if data and 'person_id' in data else None + + if data and not post_id and not person_id: + raise Exception('missing_parameters') + else: + replies = cached_reply_list(post_id, person_id, sort, max_depth) + + if auth: + try: + user_id = authorise_api_user(auth) + except Exception as e: + raise e + + # user_id: the logged in user + # person_id: the author of the posts being requested + + start = (page - 1) * limit + end = start + limit + replies = replies[start:end] + + replylist = [] + for reply in replies: + try: + replylist.append(reply_view(reply=reply, variant=2, user_id=user_id)) + except: + continue + list_json = { + "comments": replylist + } + + return list_json + + +# would be in app/constants.py +SRC_API = 3 + +def post_reply_like(auth, data): + try: + required(['comment_id', 'score'], data) + integer_expected(['comment_id', 'score'], data) + except: + raise + + score = data['score'] + reply_id = data['comment_id'] + if score == 1: + direction = 'upvote' + elif score == -1: + direction = 'downvote' + else: + score = 0 + direction = 'reversal' + + try: + user_id = vote_for_reply(reply_id, direction, SRC_API, auth) + cache.delete_memoized(cached_reply_list) + reply_json = reply_view(reply=reply_id, variant=4, user_id=user_id, my_vote=score) + return reply_json + except: + raise + + + diff --git a/app/api/alpha/utils/site.py b/app/api/alpha/utils/site.py new file mode 100644 index 00000000..869a579b --- /dev/null +++ b/app/api/alpha/utils/site.py @@ -0,0 +1,95 @@ +from app import db +from app.utils import authorise_api_user +from app.models import Language + +from flask import current_app, g + +from sqlalchemy import text + +def users_total(): + return db.session.execute(text( + 'SELECT COUNT(id) as c FROM "user" WHERE ap_id is null AND verified is true AND banned is false AND deleted is false')).scalar() + +def get_site(auth): + if auth: + try: + user = authorise_api_user(auth, return_type='model') + except Exception as e: + raise e + else: + user = None + + logo = g.site.logo if g.site.logo else '/static/images/logo2.png' + site = { + "enable_downvotes": g.site.enable_downvotes, + "icon": f"https://{current_app.config['SERVER_NAME']}{logo}", + "name": g.site.name, + "actor_id": f"https://{current_app.config['SERVER_NAME']}/", + "user_count": users_total(), + "all_languages": [] + } + if g.site.sidebar: + site['sidebar'] = g.site.sidebar + if g.site.description: + site['description'] = g.site.description + for language in Language.query.all(): + site["all_languages"].append({ + "id": language.id, + "code": language.code, + "name": language.name + }) + + if user: + my_user = { + "local_user_view": { + "local_user": { + "show_nsfw": not user.hide_nsfw == 1, + "default_sort_type": user.default_sort.capitalize(), + "default_listing_type": user.default_filter.capitalize(), + "show_scores": True, + "show_bot_accounts": not user.ignore_bots == 1, + "show_read_posts": True, + }, + "person": { + "id": user.id, + "user_name": user.user_name, + "banned": user.banned, + "published": user.created.isoformat() + 'Z', + "actor_id": user.public_url()[8:], + "local": True, + "deleted": user.deleted, + "bot": user.bot, + "instance_id": 1 + }, + "counts": { + "person_id": user.id, + "post_count": user.post_count, + "comment_count": user.post_reply_count + } + }, + #"moderates": [], + #"follows": [], + "community_blocks": [], # TODO + "instance_blocks": [], # TODO + "person_blocks": [], # TODO + "discussion_languages": [] # TODO + } + """ + Note: Thunder doesn't use moderates[] and follows[] from here, but it would be more efficient if it did (rather than getting them from /user and /community) + cms = CommunityMember.query.filter_by(user_id=user_id, is_moderator=True) + for cm in cms: + my_user['moderates'].append({'community': Community.api_json(variant=1, id=cm.community_id, stub=True), 'moderator': User.api_json(variant=1, id=user_id, stub=True)}) + cms = CommunityMember.query.filter_by(user_id=user_id, is_banned=False) + for cm in cms: + my_user['follows'].append({'community': Community.api_json(variant=1, id=cm.community_id, stub=True), 'follower': User.api_json(variant=1, id=user_id, stub=True)}) + """ + + data = { + "version": "1.0.0", + "site": site + } + if user: + data['my_user'] = my_user + + return data + diff --git a/app/api/alpha/utils/user.py b/app/api/alpha/utils/user.py new file mode 100644 index 00000000..d8af39b4 --- /dev/null +++ b/app/api/alpha/utils/user.py @@ -0,0 +1,36 @@ +from app.api.alpha.views import user_view +from app.utils import authorise_api_user +from app.api.alpha.utils.post import get_post_list +from app.api.alpha.utils.reply import get_reply_list + + +def get_user(auth, data): + if not data or ('person_id' not in data and 'username' not in data): + raise Exception('missing_parameters') + + person_id = int(data['person_id']) # TODO: handle 'username' (was passed on login, as a way to get subscription list, but temporarily removed) + + user_id = None + if auth: + try: + user_id = authorise_api_user(auth) + auth = None + except Exception as e: + raise e + + # user_id = logged in user, person_id = person who's posts, comments etc are being fetched + + # bit unusual. have to help construct the json here rather than in views, to avoid circular dependencies + post_list = get_post_list(auth, data, user_id) + reply_list = get_reply_list(auth, data, user_id) + + try: + user_json = user_view(user=person_id, variant=3) + user_json['posts'] = post_list['posts'] + user_json['comments'] = reply_list['comments'] + return user_json + except: + raise + + + diff --git a/app/api/alpha/utils/validators.py b/app/api/alpha/utils/validators.py new file mode 100644 index 00000000..c5692e9e --- /dev/null +++ b/app/api/alpha/utils/validators.py @@ -0,0 +1,45 @@ +def string_expected(values: list, data: dict): + for v in values: + if v in data: + if (not isinstance(data[v], str) and not isinstance(data[v], type(None))): + raise Exception('string_expected_for_' + v) + + +def integer_expected(values: list, data: dict): + for v in values: + if v in data: + if (not isinstance(data[v], int) and not isinstance(data[v], type(None))) or isinstance(data[v], bool): + raise Exception('integer_expected_for_' + v) + + +def boolean_expected(values: list, data: dict): + for v in values: + if v in data: + if (not isinstance(data[v], bool) and not isinstance(data[v], type(None))): + raise Exception('boolean_expected_for_' + v) + + +def array_of_strings_expected(values: list, data: dict): + for v in values: + if v in data: + if (not isinstance(data[v], list) and not isinstance(data[v], type(None))): + raise Exception('array_expected_for_' + v) + for i in data[v]: + if not isinstance(i, str): + raise Exception('array_of_strings_expected_for_' + v) + + +def array_of_integers_expected(values: list, data: dict): + for v in values: + if v in data: + if (not isinstance(data[v], list) and not isinstance(data[v], type(None))): + raise Exception('array_expected_for_' + v) + for i in data[v]: + if not isinstance(i, int): + raise Exception('array_of_integers_expected_for_' + v) + + +def required(values: list, data: dict): + for v in values: + if v not in data: + raise Exception('missing_required_' + v + '_field') diff --git a/app/api/alpha/views.py b/app/api/alpha/views.py new file mode 100644 index 00000000..d0a93d86 --- /dev/null +++ b/app/api/alpha/views.py @@ -0,0 +1,312 @@ +from app import cache, db +from app.constants import * +from app.models import Community, CommunityMember, Post, PostReply, PostVote, User + +from sqlalchemy import text + +# 'stub' param: set to True to exclude optional fields + + +def post_view(post: Post | int, variant, stub=False, user_id=None, my_vote=0): + if isinstance(post, int): + post = Post.query.get(post) + if not post or post.deleted: + raise Exception('post_not_found') + + # Variant 1 - models/post/post.dart + if variant == 1: + include = ['id', 'title', 'user_id', 'community_id', 'deleted', 'nsfw', 'sticky'] + v1 = {column.name: getattr(post, column.name) for column in post.__table__.columns if column.name in include} + v1.update({'published': post.posted_at.isoformat() + 'Z', + 'ap_id': post.profile_id(), + 'local': post.is_local(), + 'language_id': post.language_id if post.language_id else 0, + 'removed': post.deleted, + 'locked': not post.comments_enabled}) + if post.body and not stub: + v1['body'] = post.body + if post.edited_at: + v1['edited_at'] = post.edited_at.isoformat() + 'Z' + if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO: + if post.url: + v1['url'] = post.url + if post.image_id: + v1['thumbnail_url'] = post.image.thumbnail_url() + if post.image.alt_text: + v1['alt_text'] = post.image.alt_text + if post.type == POST_TYPE_IMAGE: + if post.image_id: + v1['url'] = post.image.view_url() + v1['thumbnail_url'] = post.image.medium_url() + if post.image.alt_text: + v1['alt_text'] = post.image.alt_text + + return v1 + + # Variant 2 - views/post_view.dart - /post/list api endpoint + if variant == 2: + # counts - models/post/post_aggregates.dart + counts = {'post_id': post.id, 'comments': post.reply_count, 'score': post.score, 'upvotes': post.up_votes, 'downvotes': post.down_votes, + 'published': post.posted_at.isoformat() + 'Z', 'newest_comment_time': post.last_active.isoformat() + 'Z'} + v2 = {'post': post_view(post=post, variant=1, stub=stub), 'counts': counts, 'banned_from_community': False, 'subscribed': 'NotSubscribed', + 'saved': False, 'read': False, 'hidden': False, 'creator_blocked': False, 'unread_comments': post.reply_count, 'my_vote': my_vote} + + try: + creator = user_view(user=post.user_id, variant=1, stub=True) + community = community_view(community=post.community_id, variant=1, stub=True) + v2.update({'creator': creator, 'community': community}) + except: + raise + + if not stub: + banned = db.session.execute(text('SELECT user_id FROM "community_ban" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': post.user_id, 'community_id': post.community_id}).scalar() + creator_banned_from_community = True if banned else False + moderator = db.session.execute(text('SELECT is_moderator FROM "community_member" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': post.user_id, 'community_id': post.community_id}).scalar() + creator_is_moderator = True if moderator else False + admin = db.session.execute(text('SELECT user_id FROM "user_role" WHERE user_id = :user_id and role_id = 4'), {'user_id': post.user_id}).scalar() + creator_is_admin = True if admin else False + v2.update({'creator_banned_from_community': creator_banned_from_community, + 'creator_is_moderator': creator_is_moderator, + 'creator_is_admin': creator_is_admin}) + + if my_vote == 0 and user_id is not None: + effect = db.session.execute(text('SELECT effect FROM "post_vote" WHERE post_id = :post_id and user_id = :user_id'), {'post_id': post.id, 'user_id': user_id}).scalar() + if effect: + v2['my_vote'] = int(effect) + + return v2 + + # Variant 3 - models/post/get_post_response.dart - /post api endpoint + if variant == 3: + modlist = cached_modlist_for_community(post.community_id) + + xplist = [] + if post.cross_posts: + for xp_id in post.cross_posts: + entry = post_view(post=xp_id, variant=2, stub=True) + xplist.append(entry) + + v3 = {'post_view': post_view(post=post, variant=2, user_id=user_id), + 'community_view': community_view(community=post.community_id, variant=2), + 'moderators': modlist, + 'cross_posts': xplist} + + return v3 + + # Variant 4 - models/post/post_response.dart - /post/like api endpoint + if variant == 4: + v4 = {'post_view': post_view(post=post, variant=2, user_id=user_id)} + + return v4 + + +@cache.memoize(timeout=600) +def cached_user_view_variant_1(user: User, stub=False): + include = ['id', 'user_name', 'title', 'banned', 'deleted', 'bot'] + v1 = {column.name: getattr(user, column.name) for column in user.__table__.columns if column.name in include} + v1.update({'published': user.created.isoformat() + 'Z', + 'actor_id': user.public_url(), + 'local': user.is_local(), + 'instance_id': user.instance_id if user.instance_id else 1}) + if user.about and not stub: + v1['about'] = user.about + if user.avatar_id: + v1['avatar'] = user.avatar.view_url() + if user.cover_id and not stub: + v1['banner'] = user.cover.view_url() + + return v1 + + +def user_view(user: User | int, variant, stub=False): + if isinstance(user, int): + user = User.query.get(user) + if not user: + raise Exception('user_not_found 1') + + # Variant 1 - models/person/person.dart + if variant == 1: + return cached_user_view_variant_1(user=user, stub=stub) + + # Variant 2 - views/person_view.dart + if variant == 2: + counts = {'person_id': user.id, 'post_count': user.post_count, 'comment_count': user.post_reply_count} + v2 = {'person': user_view(user=user, variant=1), 'counts': counts, 'is_admin': user.is_admin()} + return v2 + + # Variant 3 - models/user/get_person_details.dart - /user?person_id api endpoint + modlist = cached_modlist_for_user(user) + + v3 = {'person_view': user_view(user=user, variant=2), + 'moderates': modlist, + 'posts': [], + 'comments': []} + return v3 + + +@cache.memoize(timeout=600) +def cached_community_view_variant_1(community: Community, stub=False): + include = ['id', 'name', 'title', 'banned', 'nsfw', 'restricted_to_mods'] + v1 = {column.name: getattr(community, column.name) for column in community.__table__.columns if column.name in include} + v1.update({'published': community.created_at.isoformat() + 'Z', + 'updated': community.created_at.isoformat() + 'Z', + 'deleted': False, + 'removed': False, + 'actor_id': community.public_url(), + 'local': community.is_local(), + 'hidden': not community.show_all, + 'instance_id': community.instance_id if community.instance_id else 1}) + if community.description and not stub: + v1['description'] = community.description + if community.icon_id: + v1['icon'] = community.icon.view_url() + if community.image_id and not stub: + v1['banner'] = community.image.view_url() + + return v1 + + +def community_view(community: Community | int | str, variant, stub=False, user_id=None): + if isinstance(community, int): + community = Community.query.get(community) + elif isinstance(community, str): + community = Community.query.filter_by(name=community).first() + if not community: + raise Exception('community_not_found') + + # Variant 1 - models/community/community.dart + if variant == 1: + return cached_community_view_variant_1(community=community, stub=stub) + + # Variant 2 - views/community_view.dart - /community/list api endpoint + if variant == 2: + # counts - models/community/community_aggregates + include = ['id', 'subscriptions_count', 'post_count', 'post_reply_count'] + counts = {column.name: getattr(community, column.name) for column in community.__table__.columns if column.name in include} + counts.update({'published': community.created_at.isoformat() + 'Z'}) + v2 = {'community': community_view(community=community, variant=1, stub=stub), 'subscribed': 'NotSubscribed', 'blocked': False, 'counts': counts} + return v2 + + # Variant 3 - models/community/get_community_response.dart - /community api endpoint + if variant == 3: + modlist = cached_modlist_for_community(community.id) + + v3 = {'community_view': community_view(community=community, variant=2), + 'moderators': modlist, + 'discussion_languages': []} + return v3 + + + +# would be better to incrementally add to a post_reply.path field +@cache.memoize(timeout=86400) +def calculate_path(reply): + path = "0." + str(reply.id) + if reply.depth == 1: + path = "0." + str(reply.parent_id) + "." + str(reply.id) + elif reply.depth > 1: + path = "0" + parent_id = reply.parent_id + depth = reply.depth - 1 + path_ids = [reply.id, reply.parent_id] + while depth > 0: + pid = db.session.execute(text('SELECT parent_id FROM "post_reply" WHERE id = :parent_id'), {'parent_id': parent_id}).scalar() + path_ids.append(pid) + parent_id = pid + depth -= 1 + for pid in path_ids[::-1]: + path += "." + str(pid) + return path + + +# would be better to incrementally add to a post_reply.child_count field (walk along .path, and ++ each one) +@cache.memoize(timeout=86400) +def calculate_if_has_children(reply): # result used as True / False + return db.session.execute(text('SELECT COUNT(id) AS c FROM "post_reply" WHERE parent_id = :id'), {'id': reply.id}).scalar() + + +def reply_view(reply: PostReply | int, variant, user_id=None, my_vote=0): + if isinstance(reply, int): + reply = PostReply.query.get(reply) + if not reply or reply.deleted: + raise Exception('reply_not_found') + + + # Variant 1 - models/comment/comment.dart + if variant == 1: + include = ['id', 'user_id', 'post_id', 'body', 'deleted'] + v1 = {column.name: getattr(reply, column.name) for column in reply.__table__.columns if column.name in include} + + v1['path'] = calculate_path(reply) + + v1.update({'published': reply.posted_at.isoformat() + 'Z', + 'ap_id': reply.profile_id(), + 'local': reply.is_local(), + 'language_id': reply.language_id if reply.language_id else 0, + 'removed': reply.deleted, + 'distinguished': False}) + return v1 + + # Variant 2 - views/comment_view.dart - /comment/list api endpoint + if variant == 2: + # counts - models/comment/comment_aggregates.dart + counts = {'comment_id': reply.id, 'score': reply.score, 'upvotes': reply.up_votes, 'downvotes': reply.down_votes, + 'published': reply.posted_at.isoformat() + 'Z', 'child_count': 1 if calculate_if_has_children(reply) else 0} + v2 = {'comment': reply_view(reply=reply, variant=1), 'counts': counts, 'banned_from_community': False, 'subscribed': 'NotSubscribed', + 'saved': False, 'creator_blocked': False, 'my_vote': my_vote} + try: + creator = user_view(user=reply.user_id, variant=1, stub=True) + community = community_view(community=reply.community_id, variant=1, stub=True) + post = post_view(post=reply.post_id, variant=1) + v2.update({'creator': creator, 'community': community, 'post': post}) + except: + raise + + banned = db.session.execute(text('SELECT user_id FROM "community_ban" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': reply.user_id, 'community_id': reply.community_id}).scalar() + creator_banned_from_community = True if banned else False + moderator = db.session.execute(text('SELECT is_moderator FROM "community_member" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': reply.user_id, 'community_id': reply.community_id}).scalar() + creator_is_moderator = True if moderator else False + admin = db.session.execute(text('SELECT user_id FROM "user_role" WHERE user_id = :user_id and role_id = 4'), {'user_id': reply.user_id}).scalar() + creator_is_admin = True if admin else False + v2.update({'creator_banned_from_community': creator_banned_from_community, 'creator_is_moderator': creator_is_moderator, 'creator_is_admin': creator_is_admin}) + + if my_vote == 0 and user_id is not None: + effect = db.session.execute(text('SELECT effect FROM "post_reply_vote" WHERE post_reply_id = :post_reply_id and user_id = :user_id'), {'post_reply_id': reply.id, 'user_id': user_id}).scalar() + if effect: + v2['my_vote'] = int(effect) + + return v2 + + # Variant 3 - would be for /comment api endpoint + + # Variant 4 - models/comment/comment_response.dart - /comment/like api endpoint + if variant == 4: + v4 = {'comment_view': reply_view(reply=reply, variant=2, user_id=user_id)} + + return v4 + + +@cache.memoize(timeout=86400) +def cached_modlist_for_community(community_id): + moderator_ids = db.session.execute(text('SELECT user_id FROM "community_member" WHERE community_id = :community_id and is_moderator = True'), {'community_id': community_id}).scalars() + modlist = [] + for m_id in moderator_ids: + entry = { + 'community': community_view(community=community_id, variant=1, stub=True), + 'moderator': user_view(user=m_id, variant=1, stub=True) + } + modlist.append(entry) + return modlist + + +@cache.memoize(timeout=86400) +def cached_modlist_for_user(user): + community_ids = db.session.execute(text('SELECT community_id FROM "community_member" WHERE user_id = :user_id and is_moderator = True'), {'user_id': user.id}).scalars() + modlist = [] + for c_id in community_ids: + entry = { + 'community': community_view(community=c_id, variant=1, stub=True), + 'moderator': user_view(user=user, variant=1, stub=True) + } + modlist.append(entry) + return modlist diff --git a/app/models.py b/app/models.py index 3eab08db..82734a88 100644 --- a/app/models.py +++ b/app/models.py @@ -1008,6 +1008,13 @@ class User(UserMixin, db.Model): return list(db.session.execute(text('SELECT user_id FROM "notification_subscription" WHERE entity_id = :user_id AND type = :type '), {'user_id': self.id, 'type': NOTIF_USER}).scalars()) + def encode_jwt_token(self): + try: + payload = {'sub': str(self.id), 'iss': current_app.config['SERVER_NAME'], 'iat': int(time())} + return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') + except Exception as e: + return str(e) + class ActivityLog(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/shared/auth.py b/app/shared/auth.py new file mode 100644 index 00000000..f159d220 --- /dev/null +++ b/app/shared/auth.py @@ -0,0 +1,111 @@ +from app import db +from app.auth.util import ip2location +from app.models import IpBan, User, utcnow +from app.utils import ip_address, user_ip_banned, user_cookie_banned, banned_ip_addresses, gibberish +from app.api.alpha.utils.validators import required, string_expected + +from datetime import datetime + +from flask import redirect, url_for, flash, request, make_response, session, Markup +from flask_babel import _ + +# would be in app/constants.py +SRC_WEB = 1 +SRC_PUB = 2 +SRC_API = 3 + + +# function can be shared between WEB and API (only API calls it for now) + +def log_user_in(input, src): + if src == SRC_WEB: + username = input.user_name.data + password = input.password.data + elif src == SRC_API: + try: + required(["username_or_email", "password"], input) + string_expected(["username_or_email", "password"], input) + except Exception: + raise + + username = input['username_or_email'] + password = input['password'] + else: + return None + + user = User.query.filter_by(user_name=username, ap_id=None).first() + + if user is None or user.deleted: + if src == SRC_WEB: + flash(_('No account exists with that user name.'), 'error') + return redirect(url_for('auth.login')) + elif src == SRC_API: + raise Exception('incorrect_login') + + if not user.check_password(password): + if src == SRC_WEB: + if user.password_hash is None: + message = Markup(_('Invalid password. Please reset your password.')) + flash(message, 'error') + return redirect(url_for('auth.login')) + flash(_('Invalid password')) + return redirect(url_for('auth.login')) + elif src == SRC_API: + raise Exception('incorrect_login') + + if user.id != 1 and (user.banned or user_ip_banned() or user_cookie_banned()): + # Detect if a banned user tried to log in from a new IP address + if user.banned and not user_ip_banned(): + # If so, ban their new IP address as well + new_ip_ban = IpBan(ip_address=ip_address(), notes=user.user_name + ' used new IP address') + db.session.add(new_ip_ban) + db.session.commit() + cache.delete_memoized(banned_ip_addresses) + + if src == SRC_WEB: + flash(_('You have been banned.'), 'error') + + response = make_response(redirect(url_for('auth.login'))) + + # Set a cookie so we have another way to track banned people + response.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30)) + return response + elif src == SRC_API: + raise Exception('incorrect_login') + + if src == SRC_WEB: + if user.waiting_for_approval(): + return redirect(url_for('auth.please_wait')) + login_user(user, remember=True) + session['ui_language'] = user.interface_language + + user.last_seen = utcnow() + user.ip_address = ip_address() + ip_address_info = ip2location(user.ip_address) + user.ip_address_country = ip_address_info['country'] if ip_address_info else user.ip_address_country + db.session.commit() + + if src == SRC_WEB: + next_page = request.args.get('next') + if not next_page or url_parse(next_page).netloc != '': + if len(user.communities()) == 0: + next_page = url_for('topic.choose_topics') + else: + next_page = url_for('main.index') + response = make_response(redirect(next_page)) + if input.low_bandwidth_mode.data: + response.set_cookie('low_bandwidth', '1', expires=datetime(year=2099, month=12, day=30)) + else: + response.set_cookie('low_bandwidth', '0', expires=datetime(year=2099, month=12, day=30)) + return response + elif src == SRC_API: + token = user.encode_jwt_token() + if token: + login_json = { + "jwt": token, + "registration_created": user.verified, + "verify_email_sent": True + } + return login_json + else: + raise Exception('could_not_generate_token') diff --git a/app/shared/post.py b/app/shared/post.py new file mode 100644 index 00000000..7c8d0f2a --- /dev/null +++ b/app/shared/post.py @@ -0,0 +1,94 @@ +from app import cache +from app.activitypub.signature import default_context, post_request_in_background +from app.community.util import send_to_remote_instance +from app.models import Post, User +from app.utils import gibberish, instance_banned, render_template, authorise_api_user, recently_upvoted_posts, recently_downvoted_posts + +from flask import current_app, request +from flask_login import current_user + + +# would be in app/constants.py +SRC_WEB = 1 +SRC_PUB = 2 +SRC_API = 3 + +# function can be shared between WEB and API (only API calls it for now) +# post_vote in app/post/routes would just need to do 'return vote_for_post(post_id, vote_direction, SRC_WEB)' + +def vote_for_post(post_id: int, vote_direction, src, auth=None): + if src == SRC_API and auth is not None: + post = Post.query.get(post_id) + if not post: + raise Exception('post_not_found') + try: + user = authorise_api_user(auth, return_type='model') + except: + raise + else: + post = Post.query.get_or_404(post_id) + user = current_user + + undo = post.vote(user, vote_direction) + + if not post.community.local_only: + if undo: + action_json = { + 'actor': user.public_url(not(post.community.instance.votes_are_public() and user.vote_privately())), + 'type': 'Undo', + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/undo/{gibberish(15)}", + 'audience': post.community.public_url(), + 'object': { + 'actor': user.public_url(not(post.community.instance.votes_are_public() and user.vote_privately())), + 'object': post.public_url(), + 'type': undo, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/{undo.lower()}/{gibberish(15)}", + 'audience': post.community.public_url() + } + } + else: + action_type = 'Like' if vote_direction == 'upvote' else 'Dislike' + action_json = { + 'actor': user.public_url(not(post.community.instance.votes_are_public() and user.vote_privately())), + 'object': post.profile_id(), + 'type': action_type, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/{action_type.lower()}/{gibberish(15)}", + 'audience': post.community.public_url() + } + if post.community.is_local(): + announce = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + "type": 'Announce', + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "actor": post.community.public_url(), + "cc": [ + post.community.ap_followers_url + ], + '@context': default_context(), + 'object': action_json + } + for instance in post.community.following_instances(): + if instance.inbox and not user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): + send_to_remote_instance(instance.id, post.community.id, announce) + else: + post_request_in_background(post.community.ap_inbox_url, action_json, user.private_key, + user.public_url(not(post.community.instance.votes_are_public() and user.vote_privately())) + '#main-key') + + + if src == SRC_API: + return user.id + else: + recently_upvoted = [] + recently_downvoted = [] + if vote_direction == 'upvote' and undo is None: + recently_upvoted = [post_id] + elif vote_direction == 'downvote' and undo is None: + recently_downvoted = [post_id] + cache.delete_memoized(recently_upvoted_posts, user.id) + cache.delete_memoized(recently_downvoted_posts, user.id) + + template = 'post/_post_voting_buttons.html' if request.args.get('style', '') == '' else 'post/_post_voting_buttons_masonry.html' + return render_template(template, post=post, community=post.community, recently_upvoted=recently_upvoted, + recently_downvoted=recently_downvoted) diff --git a/app/shared/reply.py b/app/shared/reply.py new file mode 100644 index 00000000..38db1cfc --- /dev/null +++ b/app/shared/reply.py @@ -0,0 +1,97 @@ +from app import cache +from app.activitypub.signature import default_context, post_request_in_background +from app.community.util import send_to_remote_instance +from app.models import PostReply, User +from app.utils import gibberish, instance_banned, render_template, authorise_api_user, recently_upvoted_post_replies, recently_downvoted_post_replies + +from flask import current_app, request +from flask_login import current_user + + +# would be in app/constants.py +SRC_WEB = 1 +SRC_PUB = 2 +SRC_API = 3 + +# function can be shared between WEB and API (only API calls it for now) +# comment_vote in app/post/routes would just need to do 'return vote_for_reply(reply_id, vote_direction, SRC_WEB)' + +def vote_for_reply(reply_id: int, vote_direction, src, auth=None): + if src == SRC_API and auth is not None: + reply = PostReply.query.get(reply_id) + if not reply: + raise Exception('reply_not_found') + try: + user = authorise_api_user(auth, return_type='model') + except: + raise + else: + reply = PostReply.query.get_or_404(post_id) + user = current_user + + undo = reply.vote(user, vote_direction) + + if not reply.community.local_only: + if undo: + action_json = { + 'actor': user.public_url(not(reply.community.instance.votes_are_public() and user.vote_privately())), + 'type': 'Undo', + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/undo/{gibberish(15)}", + 'audience': reply.community.public_url(), + 'object': { + 'actor': user.public_url(not(reply.community.instance.votes_are_public() and user.vote_privately())), + 'object': reply.public_url(), + 'type': undo, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/{undo.lower()}/{gibberish(15)}", + 'audience': reply.community.public_url() + } + } + else: + action_type = 'Like' if vote_direction == 'upvote' else 'Dislike' + action_json = { + 'actor': user.public_url(not(reply.community.instance.votes_are_public() and user.vote_privately())), + 'object': reply.public_url(), + 'type': action_type, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/{action_type.lower()}/{gibberish(15)}", + 'audience': reply.community.public_url() + } + if reply.community.is_local(): + announce = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + "type": 'Announce', + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "actor": reply.community.ap_profile_id, + "cc": [ + reply.community.ap_followers_url + ], + '@context': default_context(), + 'object': action_json + } + for instance in reply.community.following_instances(): + if instance.inbox and not user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): + send_to_remote_instance(instance.id, reply.community.id, announce) + else: + post_request_in_background(reply.community.ap_inbox_url, action_json, user.private_key, + user.public_url(not(reply.community.instance.votes_are_public() and user.vote_privately())) + '#main-key') + + if src == SRC_API: + return user.id + else: + recently_upvoted = [] + recently_downvoted = [] + if vote_direction == 'upvote' and undo is None: + recently_upvoted = [reply_id] + elif vote_direction == 'downvote' and undo is None: + recently_downvoted = [reply_id] + cache.delete_memoized(recently_upvoted_post_replies, user.id) + cache.delete_memoized(recently_downvoted_post_replies, user.id) + + return render_template('post/_reply_voting_buttons.html', comment=reply, + recently_upvoted_replies=recently_upvoted, + recently_downvoted_replies=recently_downvoted, + community=reply.community) + + + diff --git a/app/utils.py b/app/utils.py index fc6282d7..4b39a7a8 100644 --- a/app/utils.py +++ b/app/utils.py @@ -19,6 +19,7 @@ from functools import wraps import flask from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning import warnings +import jwt warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning) @@ -1261,3 +1262,23 @@ def add_to_modlog_activitypub(action: str, actor: User, community_id: int = None db.session.add(ModLog(user_id=actor.id, community_id=community_id, type=action_type, action=action, reason=reason, link=link, link_text=link_text, public=get_setting('public_modlog', False))) db.session.commit() + + +def authorise_api_user(auth, return_type='id'): + token = auth[7:] # remove 'Bearer ' + + try: + decoded = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=["HS256"]) + if decoded: + user_id = decoded['sub'] + issued_at = decoded['iat'] # use to check against blacklisted JWTs + user = User.query.filter_by(id=user_id, ap_id=None, verified=True, banned=False, deleted=False).scalar() + if user: + if return_type == 'model': + return user + else: + return user_id + else: + raise Exception('incorrect_login') + except jwt.InvalidTokenError: + raise Exception('invalid_token')