diff --git a/app/activitypub/util.py b/app/activitypub/util.py index d92d3bf2..820c652b 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -945,6 +945,7 @@ def post_json_to_model(activity_log, post_json, user, community) -> Post: post.image = image db.session.add(post) community.post_count += 1 + user.post_count += 1 activity_log.result = 'success' db.session.commit() diff --git a/app/admin/routes.py b/app/admin/routes.py index 881b0516..4223ab19 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -662,6 +662,7 @@ def admin_communities_no_topic(): def admin_community_edit(community_id): form = EditCommunityForm() community = Community.query.get_or_404(community_id) + old_topic_id = community.topic_id if community.topic_id else None form.topic.choices = topics_for_form(0) form.languages.choices = languages_for_form() if form.validate_on_submit(): @@ -708,11 +709,17 @@ def admin_community_edit(community_id): community.languages.append(Language.query.get(language_choice)) # Always include the undetermined language, so posts with no language will be accepted community.languages.append(Language.query.filter(Language.code == 'und').first()) + db.session.commit() + + if community.topic_id != old_topic_id: + if community.topic_id: + community.topic.num_communities = community.topic.communities.count() + if old_topic_id: + topic = Topic.query.get(old_topic_id) + if topic: + topic.num_communities = topic.communities.count() + db.session.commit() - db.session.commit() - if community.topic_id: - community.topic.num_communities = community.topic.communities.count() - db.session.commit() flash(_('Saved')) return redirect(url_for('admin.admin_communities')) else: diff --git a/app/api/alpha/routes.py b/app/api/alpha/routes.py index bda1291d..02b9f7d0 100644 --- a/app/api/alpha/routes.py +++ b/app/api/alpha/routes.py @@ -2,8 +2,8 @@ from app.api.alpha import bp from app.api.alpha.utils import get_site, \ get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, \ get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, \ - get_community_list, get_community, \ - get_user + get_community_list, get_community, post_community_follow, post_community_block, \ + get_user, post_user_block from app.shared.auth import log_user_in from flask import current_app, jsonify, request @@ -27,7 +27,7 @@ def get_alpha_community(): if not current_app.debug: return jsonify({'error': 'alpha api routes only available in debug mode'}) try: - auth = None + auth = request.headers.get('Authorization') data = request.args.to_dict() or None return jsonify(get_community(auth, data)) except Exception as ex: @@ -46,6 +46,30 @@ def get_alpha_community_list(): return jsonify({"error": str(ex)}), 400 +@bp.route('/api/alpha/community/follow', methods=['POST']) +def post_alpha_community_follow(): + 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_community_follow(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +@bp.route('/api/alpha/community/block', methods=['POST']) +def post_alpha_community_block(): + 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_community_block(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(): @@ -170,7 +194,7 @@ def get_alpha_user(): @bp.route('/api/alpha/user/login', methods=['POST']) -def post_alpha_login(): +def post_alpha_user_login(): if not current_app.debug: return jsonify({'error': 'alpha api routes only available in debug mode'}) try: @@ -181,6 +205,18 @@ def post_alpha_login(): return jsonify({"error": str(ex)}), 400 +@bp.route('/api/alpha/user/block', methods=['POST']) +def post_alpha_user_block(): + 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_user_block(auth, data)) + 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 @@ -202,8 +238,6 @@ def alpha_miscellaneous(): @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']) @@ -219,7 +253,6 @@ def alpha_community(): @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/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']) @@ -261,7 +294,6 @@ def alpha_chat(): @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']) diff --git a/app/api/alpha/utils/__init__.py b/app/api/alpha/utils/__init__.py index cb2aac29..7c22f790 100644 --- a/app/api/alpha/utils/__init__.py +++ b/app/api/alpha/utils/__init__.py @@ -1,7 +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, put_post_save, put_post_subscribe from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe -from app.api.alpha.utils.community import get_community, get_community_list -from app.api.alpha.utils.user import get_user +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/community.py b/app/api/alpha/utils/community.py index 4300a27c..b573af73 100644 --- a/app/api/alpha/utils/community.py +++ b/app/api/alpha/utils/community.py @@ -1,7 +1,9 @@ from app import cache from app.api.alpha.views import community_view +from app.api.alpha.utils.validators import required, integer_expected, boolean_expected from app.utils import authorise_api_user from app.models import Community, CommunityMember +from app.shared.community import join_community, leave_community, block_community, unblock_community from app.utils import communities_banned_from @@ -26,8 +28,8 @@ def get_community_list(auth, data): if auth: try: user_id = authorise_api_user(auth) - except Exception as e: - raise e + except: + raise else: user_id = None @@ -55,8 +57,71 @@ def get_community(auth, data): elif 'name' in data: community = data['name'] + if auth: + try: + user_id = authorise_api_user(auth) + except: + raise + else: + user_id = None + try: - community_json = community_view(community=community, variant=3) + community_json = community_view(community=community, variant=3, stub=False, user_id=user_id) + return community_json + except: + raise + + +# would be in app/constants.py +SRC_API = 3 + +def post_community_follow(auth, data): + try: + required(['community_id', 'follow'], data) + integer_expected(['community_id'], data) + boolean_expected(['follow'], data) + except: + raise + + community_id = data['community_id'] + follow = data['follow'] + + if auth: + try: + user_id = authorise_api_user(auth) + except: + raise + else: + user_id = None + + try: + if follow == True: + user_id = join_community(community_id, SRC_API, auth) + else: + user_id = leave_community(community_id, SRC_API, auth) + community_json = community_view(community=community_id, variant=4, stub=False, user_id=user_id) + return community_json + except: + raise + + +def post_community_block(auth, data): + try: + required(['community_id', 'block'], data) + integer_expected(['community_id'], data) + boolean_expected(['block'], data) + except: + raise + + community_id = data['community_id'] + block = data['block'] + + try: + if block == True: + user_id = block_community(community_id, SRC_API, auth) + else: + user_id = unblock_community(community_id, SRC_API, auth) + community_json = community_view(community=community_id, variant=5, user_id=user_id) return community_json except: raise diff --git a/app/api/alpha/utils/post.py b/app/api/alpha/utils/post.py index 177c3e34..a6c7e0a5 100644 --- a/app/api/alpha/utils/post.py +++ b/app/api/alpha/utils/post.py @@ -3,7 +3,7 @@ from app.api.alpha.views import post_view from app.api.alpha.utils.validators import required, integer_expected, boolean_expected 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 -from app.utils import authorise_api_user +from app.utils import authorise_api_user, blocked_users, blocked_communities from datetime import timedelta from sqlalchemy import desc @@ -28,6 +28,14 @@ def cached_post_list(type, sort, user_id, community_id, community_name, person_i else: posts = Post.query.filter_by(deleted=False) + if user_id is not None: + blocked_person_ids = blocked_users(user_id) + if blocked_person_ids: + posts = posts.filter(Post.user_id.not_in(blocked_person_ids)) + blocked_community_ids = blocked_communities(user_id) + if blocked_community_ids: + posts = posts.filter(Post.community_id.not_in(blocked_community_ids)) + if sort == "Hot": posts = posts.order_by(desc(Post.ranking)).order_by(desc(Post.posted_at)) elif sort == "TopDay": @@ -49,8 +57,8 @@ def get_post_list(auth, data, user_id=None): if auth: try: user_id = authorise_api_user(auth) - except Exception as e: - raise e + except: + raise # user_id: the logged in user # person_id: the author of the posts being requested diff --git a/app/api/alpha/utils/reply.py b/app/api/alpha/utils/reply.py index 99eb9eca..e459eb06 100644 --- a/app/api/alpha/utils/reply.py +++ b/app/api/alpha/utils/reply.py @@ -1,20 +1,25 @@ from app import cache -from app.utils import authorise_api_user from app.api.alpha.utils.validators import required, integer_expected, boolean_expected from app.api.alpha.views import reply_view from app.models import PostReply from app.shared.reply import vote_for_reply, bookmark_the_post_reply, remove_the_bookmark_from_post_reply, toggle_post_reply_notification +from app.utils import authorise_api_user, blocked_users from sqlalchemy import desc - +# person_id param: the author of the reply; user_id param: the current logged-in user @cache.memoize(timeout=3) -def cached_reply_list(post_id, person_id, sort, max_depth): +def cached_reply_list(post_id, person_id, sort, max_depth, user_id): 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 user_id is not None: + blocked_person_ids = blocked_users(user_id) + if blocked_person_ids: + replies = replies.filter(PostReply.user_id.not_in(blocked_person_ids)) + if sort == "Hot": replies = replies.order_by(desc(PostReply.ranking)).order_by(desc(PostReply.posted_at)) elif sort == "Top": @@ -36,13 +41,12 @@ def get_reply_list(auth, data, user_id=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 + if auth: + try: + user_id = authorise_api_user(auth) + except: + raise + replies = cached_reply_list(post_id, person_id, sort, max_depth, user_id) # user_id: the logged in user # person_id: the author of the posts being requested diff --git a/app/api/alpha/utils/site.py b/app/api/alpha/utils/site.py index 869a579b..0bc56c75 100644 --- a/app/api/alpha/utils/site.py +++ b/app/api/alpha/utils/site.py @@ -1,4 +1,5 @@ from app import db +from app.api.alpha.views import user_view, community_view from app.utils import authorise_api_user from app.models import Language @@ -69,9 +70,9 @@ def get_site(auth): }, #"moderates": [], #"follows": [], - "community_blocks": [], # TODO + "community_blocks": [], "instance_blocks": [], # TODO - "person_blocks": [], # TODO + "person_blocks": [], "discussion_languages": [] # TODO } """ @@ -83,7 +84,12 @@ def get_site(auth): 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)}) """ - + blocked_ids = db.session.execute(text('SELECT blocked_id FROM "user_block" WHERE blocker_id = :blocker_id'), {"blocker_id": user.id}).scalars() + for blocked_id in blocked_ids: + my_user['person_blocks'].append({'person': user_view(user, variant=1, stub=True), 'target': user_view(blocked_id, variant=1, stub=True)}) + blocked_ids = db.session.execute(text('SELECT community_id FROM "community_block" WHERE user_id = :user_id'), {"user_id": user.id}).scalars() + for blocked_id in blocked_ids: + my_user['community_blocks'].append({'person': user_view(user, variant=1, stub=True), 'community': community_view(blocked_id, variant=1, stub=True)}) data = { "version": "1.0.0", "site": site diff --git a/app/api/alpha/utils/user.py b/app/api/alpha/utils/user.py index 651a3808..84459b24 100644 --- a/app/api/alpha/utils/user.py +++ b/app/api/alpha/utils/user.py @@ -2,6 +2,8 @@ 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 +from app.api.alpha.utils.validators import required, integer_expected, boolean_expected +from app.shared.user import block_another_user, unblock_another_user def get_user(auth, data): @@ -39,4 +41,26 @@ def get_user(auth, data): raise +# would be in app/constants.py +SRC_API = 3 +def post_user_block(auth, data): + try: + required(['person_id', 'block'], data) + integer_expected(['post_id'], data) + boolean_expected(['block'], data) + except: + raise + + person_id = data['person_id'] + block = data['block'] + + try: + if block == True: + user_id = block_another_user(person_id, SRC_API, auth) + else: + user_id = unblock_another_user(person_id, SRC_API, auth) + user_json = user_view(user=person_id, variant=4, user_id=user_id) + return user_json + except: + raise diff --git a/app/api/alpha/views.py b/app/api/alpha/views.py index 1a2c8513..4e2ea483 100644 --- a/app/api/alpha/views.py +++ b/app/api/alpha/views.py @@ -3,6 +3,7 @@ from __future__ import annotations from app import cache, db from app.constants import * from app.models import Community, CommunityMember, Post, PostReply, PostVote, User +from app.utils import blocked_communities from sqlalchemy import text @@ -50,8 +51,12 @@ def post_view(post: Post | int, variant, stub=False, user_id=None, my_vote=0): # 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'} - bookmarked = db.session.execute(text('SELECT user_id FROM "post_bookmark" WHERE post_id = :post_id and user_id = :user_id'), {'post_id': post.id, 'user_id': user_id}).scalar() - post_sub = db.session.execute(text('SELECT user_id FROM "notification_subscription" WHERE type = :type and entity_id = :entity_id and user_id = :user_id'), {'type': NOTIF_POST, 'entity_id': post.id, 'user_id': user_id}).scalar() + if user_id: + bookmarked = db.session.execute(text('SELECT user_id FROM "post_bookmark" WHERE post_id = :post_id and user_id = :user_id'), {'post_id': post.id, 'user_id': user_id}).scalar() + post_sub = db.session.execute(text('SELECT user_id FROM "notification_subscription" WHERE type = :type and entity_id = :entity_id and user_id = :user_id'), {'type': NOTIF_POST, 'entity_id': post.id, 'user_id': user_id}).scalar() + followed = db.session.execute(text('SELECT user_id FROM "community_member" WHERE community_id = :community_id and user_id = :user_id'), {"community_id": post.community_id, "user_id": user_id}).scalar() + else: + bookmarked = post_sub = followed = False 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() 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() @@ -72,8 +77,9 @@ def post_view(post: Post | int, variant, stub=False, user_id=None, my_vote=0): creator_banned_from_community = True if banned else False creator_is_moderator = True if moderator else False creator_is_admin = True if admin else False - v2 = {'post': post_view(post=post, variant=1, stub=stub), 'counts': counts, 'banned_from_community': False, 'subscribed': 'NotSubscribed', - 'saved': saved, 'read': False, 'hidden': False, 'creator_blocked': False, 'unread_comments': post.reply_count, 'my_vote': my_vote, 'activity_alert': activity_alert, + subscribe_type = 'Subscribed' if followed else 'NotSubscribed' + v2 = {'post': post_view(post=post, variant=1, stub=stub), 'counts': counts, 'banned_from_community': False, 'subscribed': subscribe_type, + 'saved': saved, 'read': False, 'hidden': False, 'unread_comments': post.reply_count, 'my_vote': my_vote, 'activity_alert': activity_alert, 'creator_banned_from_community': creator_banned_from_community, 'creator_is_moderator': creator_is_moderator, 'creator_is_admin': creator_is_admin} try: @@ -127,7 +133,8 @@ def cached_user_view_variant_1(user: User, stub=False): return v1 -def user_view(user: User | int, variant, stub=False): +# 'user' param can be anyone (including the logged in user), 'user_id' param belongs to the user making the request +def user_view(user: User | int, variant, stub=False, user_id=None): if isinstance(user, int): user = User.query.get(user) if not user: @@ -144,13 +151,22 @@ def user_view(user: User | int, variant, stub=False): return v2 # Variant 3 - models/user/get_person_details.dart - /user?person_id api endpoint - modlist = cached_modlist_for_user(user) + if variant == 3: + modlist = cached_modlist_for_user(user) - v3 = {'person_view': user_view(user=user, variant=2), - 'moderates': modlist, - 'posts': [], - 'comments': []} - return v3 + v3 = {'person_view': user_view(user=user, variant=2), + 'moderates': modlist, + 'posts': [], + 'comments': []} + return v3 + + # Variant 4 - models/user/block_person_response.dart - /user/block api endpoint + if variant == 4: + block = db.session.execute(text('SELECT blocker_id FROM "user_block" WHERE blocker_id = :blocker_id and blocked_id = :blocked_id'), {'blocker_id': user_id, 'blocked_id': user.id}).scalar() + blocked = True if block else False + v4 = {'person_view': user_view(user=user, variant=2), + 'blocked': blocked} + return v4 @cache.memoize(timeout=600) @@ -194,18 +210,37 @@ def community_view(community: Community | int | str, variant, stub=False, user_i 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} + if user_id: + followed = db.session.execute(text('SELECT user_id FROM "community_member" WHERE community_id = :community_id and user_id = :user_id'), {"community_id": community.id, "user_id": user_id}).scalar() + blocked = True if community.id in blocked_communities(user_id) else False + else: + followed = blocked = False + subscribe_type = 'Subscribed' if followed else 'NotSubscribed' + v2 = {'community': community_view(community=community, variant=1, stub=stub), 'subscribed': subscribe_type, 'blocked': blocked, '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), + v3 = {'community_view': community_view(community=community, variant=2, stub=False, user_id=user_id), 'moderators': modlist, 'discussion_languages': []} return v3 + # Variant 4 - models/community/community_response.dart - /community/follow api endpoint + if variant == 4: + v4 = {'community_view': community_view(community=community, variant=2, stub=False, user_id=user_id), + 'discussion_languages': []} + return v4 + + # Variant 5 - models/community/block_community_response.dart - /community/block api endpoint + if variant == 5: + block = db.session.execute(text('SELECT user_id FROM "community_block" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': user_id, 'community_id': community.id}).scalar() + blocked = True if block else False + v5 = {'community_view': community_view(community=community, variant=2, stub=False, user_id=user_id), + 'blocked': blocked} + return v5 # would be better to incrementally add to a post_reply.path field diff --git a/app/community/routes.py b/app/community/routes.py index 7c298a87..ba80efe8 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -929,6 +929,7 @@ def community_edit(community_id: int): if current_user.banned: return show_ban_message() community = Community.query.get_or_404(community_id) + old_topic_id = community.topic_id if community.topic_id else None if community.is_owner() or current_user.is_admin(): form = EditCommunityForm() form.topic.choices = topics_for_form(0) @@ -970,11 +971,16 @@ def community_edit(community_id: int): community.languages.append(Language.query.get(language_choice)) # Always include the undetermined language, so posts with no language will be accepted community.languages.append(Language.query.filter(Language.code == 'und').first()) + db.session.commit() - db.session.commit() - if community.topic: - community.topic.num_communities = community.topic.communities.count() - db.session.commit() + if community.topic_id != old_topic_id: + if community.topic_id: + community.topic.num_communities = community.topic.communities.count() + if old_topic_id: + topic = Topic.query.get(old_topic_id) + if topic: + topic.num_communities = topic.communities.count() + db.session.commit() flash(_('Saved')) return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name)) else: diff --git a/app/post/routes.py b/app/post/routes.py index 717f376c..120faf1e 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -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, \ 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, \ - permission_required + permission_required, blocked_users def show_post(post_id: int): @@ -1258,6 +1258,7 @@ def post_block_user(post_id: int): db.session.add(UserBlock(blocker_id=current_user.id, blocked_id=post.author.id)) db.session.commit() flash(_('%(name)s has been blocked.', name=post.author.user_name)) + cache.delete_memoized(blocked_users, current_user.id) # todo: federate block to post author instance @@ -1428,6 +1429,7 @@ def post_reply_block_user(post_id: int, comment_id: int): db.session.add(UserBlock(blocker_id=current_user.id, blocked_id=post_reply.author.id)) db.session.commit() flash(_('%(name)s has been blocked.', name=post_reply.author.user_name)) + cache.delete_memoized(blocked_users, current_user.id) # todo: federate block to post_reply author instance diff --git a/app/shared/community.py b/app/shared/community.py new file mode 100644 index 00000000..4b79b84e --- /dev/null +++ b/app/shared/community.py @@ -0,0 +1,223 @@ +from app import db, cache +from app.activitypub.signature import post_request +from app.constants import * +from app.models import Community, CommunityBan, CommunityBlock, CommunityJoinRequest, CommunityMember +from app.utils import authorise_api_user, blocked_communities, community_membership, joined_communities, gibberish + +from flask import abort, current_app, flash +from flask_babel import _ +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) +# call from admin.federation not tested +def join_community(community_id: int, src, auth=None, user_id=None, main_user_name=True): + if src == SRC_API: + community = Community.query.get(community_id) + if not community: + raise Exception('community_not_found') + try: + user = authorise_api_user(auth, return_type='model') + except: + raise + else: + community = Community.query.get_or_404(community_id) + if not user_id: + user = current_user + else: + user = User.query.get(user_id) + + pre_load_message = {} + if community_membership(user, community) != SUBSCRIPTION_MEMBER and community_membership(user, community) != SUBSCRIPTION_PENDING: + banned = CommunityBan.query.filter_by(user_id=user.id, community_id=community.id).first() + if banned: + if src == SRC_API: + raise Exception('banned_from_community') + else: + if main_user_name: + flash(_('You cannot join this community')) + return + else: + pre_load_message['user_banned'] = True + return pre_load_message + else: + if src == SRC_API: + return user.id + else: + if not main_user_name: + pre_load_message['status'] = 'already subscribed, or subsciption pending' + return pre_load_message + + success = True + remote = not community.is_local() + if remote: + # send ActivityPub message to remote community, asking to follow. Accept message will be sent to our shared inbox + join_request = CommunityJoinRequest(user_id=user.id, community_id=community.id) + db.session.add(join_request) + db.session.commit() + if community.instance.online(): + follow = { + "actor": user.public_url(main_user_name=main_user_name), + "to": [community.public_url()], + "object": community.public_url(), + "type": "Follow", + "id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{join_request.id}" + } + success = post_request(community.ap_inbox_url, follow, user.private_key, + user.public_url(main_user_name=main_user_name) + '#main-key', timeout=10) + if success is False or isinstance(success, str): + if 'is not in allowlist' in success: + if src == SRC_API: + raise Exception('not_in_remote_instance_allowlist') + else: + msg_to_user = f'{community.instance.domain} does not allow us to join their communities.' + if main_user_name: + flash(_(msg_to_user), 'error') + return + else: + pre_load_message['status'] = msg_to_user + return pre_load_message + else: + if src != SRC_API: + msg_to_user = "There was a problem while trying to communicate with remote server. If other people have already joined this community it won't matter." + if main_user_name: + flash(_(msg_to_user), 'error') + return + else: + pre_load_message['status'] = msg_to_user + return pre_load_message + + # for local communities, joining is instant + member = CommunityMember(user_id=user.id, community_id=community.id) + db.session.add(member) + db.session.commit() + if success is True: + cache.delete_memoized(community_membership, user, community) + cache.delete_memoized(joined_communities, user.id) + if src == SRC_API: + return user.id + else: + if main_user_name: + flash('You joined ' + community.title) + else: + pre_load_message['status'] = 'joined' + + if not main_user_name: + return pre_load_message + + # for SRC_WEB, calling function should handle if the community isn't found + + +# function can be shared between WEB and API (only API calls it for now) +def leave_community(community_id: int, src, auth=None): + if src == SRC_API: + community = Community.query.get(community_id) + if not community: + raise Exception('community_not_found') + try: + user = authorise_api_user(auth, return_type='model') + except: + raise + else: + community = Community.query.get_or_404(community_id) + user = current_user + + subscription = community_membership(user, community) + if subscription: + if subscription != SUBSCRIPTION_OWNER: + proceed = True + # Undo the Follow + if not community.is_local(): + success = True + if not community.instance.gone_forever: + undo_id = f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15) + follow = { + "actor": user.public_url(), + "to": [community.public_url()], + "object": community.public_url(), + "type": "Follow", + "id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}" + } + undo = { + 'actor': user.public_url(), + 'to': [community.public_url()], + 'type': 'Undo', + 'id': undo_id, + 'object': follow + } + success = post_request(community.ap_inbox_url, undo, user.private_key, + user.public_url() + '#main-key', timeout=10) + if success is False or isinstance(success, str): + if src != SRC_API: + flash('There was a problem while trying to unsubscribe', 'error') + return + + if proceed: + db.session.query(CommunityMember).filter_by(user_id=user.id, community_id=community.id).delete() + db.session.query(CommunityJoinRequest).filter_by(user_id=user.id, community_id=community.id).delete() + db.session.commit() + + if src != SRC_API: + flash('You have left ' + community.title) + + cache.delete_memoized(community_membership, user, community) + cache.delete_memoized(joined_communities, user.id) + else: + # todo: community deletion + if src == SRC_API: + raise Exception('need_to_make_someone_else_owner') + else: + flash('You need to make someone else the owner before unsubscribing.', 'warning') + return + + if src == SRC_API: + return user.id + else: + # let calling function handle redirect + return + + +def block_community(community_id, src, auth=None): + if src == SRC_API: + try: + user_id = authorise_api_user(auth) + except: + raise + else: + user_id = current_user.id + + existing = CommunityBlock.query.filter_by(user_id=user_id, community_id=community_id).first() + if not existing: + db.session.add(CommunityBlock(user_id=user_id, community_id=community_id)) + db.session.commit() + cache.delete_memoized(blocked_communities, user_id) + + if src == SRC_API: + return user_id + else: + return # let calling function handle confirmation flash message and redirect + + +def unblock_community(community_id, src, auth=None): + if src == SRC_API: + try: + user_id = authorise_api_user(auth) + except: + raise + else: + user_id = current_user.id + + existing_block = CommunityBlock.query.filter_by(user_id=user_id, community_id=community_id).first() + if existing_block: + db.session.delete(existing_block) + db.session.commit() + cache.delete_memoized(blocked_communities, user_id) + + if src == SRC_API: + return user_id + else: + return # let calling function handle confirmation flash message and redirect diff --git a/app/shared/post.py b/app/shared/post.py index f49de24a..c18c4de2 100644 --- a/app/shared/post.py +++ b/app/shared/post.py @@ -19,7 +19,7 @@ SRC_API = 3 # 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: + if src == SRC_API: post = Post.query.get(post_id) if not post: raise Exception('post_not_found') @@ -99,7 +99,7 @@ def vote_for_post(post_id: int, vote_direction, src, auth=None): # function can be shared between WEB and API (only API calls it for now) # post_bookmark in app/post/routes would just need to do 'return bookmark_the_post(post_id, SRC_WEB)' def bookmark_the_post(post_id: int, src, auth=None): - if src == SRC_API and auth is not None: + if src == SRC_API: post = Post.query.get(post_id) if not post or post.deleted: raise Exception('post_not_found') @@ -132,7 +132,7 @@ def bookmark_the_post(post_id: int, src, auth=None): # function can be shared between WEB and API (only API calls it for now) # post_remove_bookmark in app/post/routes would just need to do 'return remove_the_bookmark_from_post(post_id, SRC_WEB)' def remove_the_bookmark_from_post(post_id: int, src, auth=None): - if src == SRC_API and auth is not None: + if src == SRC_API: post = Post.query.get(post_id) if not post or post.deleted: raise Exception('post_not_found') @@ -164,7 +164,7 @@ def remove_the_bookmark_from_post(post_id: int, src, auth=None): # post_notification in app/post/routes would just need to do 'return toggle_post_notification(post_id, SRC_WEB)' def toggle_post_notification(post_id: int, src, auth=None): # Toggle whether the current user is subscribed to notifications about top-level replies to this post or not - if src == SRC_API and auth is not None: + if src == SRC_API: post = Post.query.get(post_id) if not post or post.deleted: raise Exception('post_not_found') diff --git a/app/shared/reply.py b/app/shared/reply.py index d1c11c6a..77169b51 100644 --- a/app/shared/reply.py +++ b/app/shared/reply.py @@ -19,7 +19,7 @@ SRC_API = 3 # 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: + if src == SRC_API: reply = PostReply.query.get(reply_id) if not reply: raise Exception('reply_not_found') @@ -99,7 +99,7 @@ def vote_for_reply(reply_id: int, vote_direction, src, auth=None): # function can be shared between WEB and API (only API calls it for now) # post_reply_bookmark in app/post/routes would just need to do 'return bookmark_the_post_reply(comment_id, SRC_WEB)' def bookmark_the_post_reply(comment_id: int, src, auth=None): - if src == SRC_API and auth is not None: + if src == SRC_API: post_reply = PostReply.query.get(comment_id) if not post_reply or post_reply.deleted: raise Exception('comment_not_found') @@ -133,7 +133,7 @@ def bookmark_the_post_reply(comment_id: int, src, auth=None): # function can be shared between WEB and API (only API calls it for now) # post_reply_remove_bookmark in app/post/routes would just need to do 'return remove_the_bookmark_from_post_reply(comment_id, SRC_WEB)' def remove_the_bookmark_from_post_reply(comment_id: int, src, auth=None): - if src == SRC_API and auth is not None: + if src == SRC_API: post_reply = PostReply.query.get(comment_id) if not post_reply or post_reply.deleted: raise Exception('comment_not_found') @@ -165,7 +165,7 @@ def remove_the_bookmark_from_post_reply(comment_id: int, src, auth=None): # post_reply_notification in app/post/routes would just need to do 'return toggle_post_reply_notification(post_reply_id, SRC_WEB)' def toggle_post_reply_notification(post_reply_id: int, src, auth=None): # Toggle whether the current user is subscribed to notifications about replies to this reply or not - if src == SRC_API and auth is not None: + if src == SRC_API: post_reply = PostReply.query.get(post_reply_id) if not post_reply or post_reply.deleted: raise Exception('comment_not_found') diff --git a/app/shared/user.py b/app/shared/user.py new file mode 100644 index 00000000..b5916b11 --- /dev/null +++ b/app/shared/user.py @@ -0,0 +1,92 @@ +from app import db, cache +from app.constants import ROLE_STAFF, ROLE_ADMIN +from app.models import UserBlock +from app.utils import authorise_api_user, blocked_users + +from flask import flash +from flask_babel import _ +from flask_login import current_user + +from sqlalchemy import text + +# would be in app/constants.py +SRC_WEB = 1 +SRC_PUB = 2 +SRC_API = 3 + +# only called from API for now, but can be called from web using [un]block_another_user(user.id, SRC_WEB) + +# user_id: the local, logged-in user +# person_id: the person they want to block + +def block_another_user(person_id, src, auth=None): + if src == SRC_API: + try: + user_id = authorise_api_user(auth) + except: + raise + else: + user_id = current_user.id + + if user_id == person_id: + if src == SRC_API: + raise Exception('cannot_block_self') + else: + flash(_('You cannot block yourself.'), 'error') + return + + role = db.session.execute(text('SELECT role_id FROM "user_role" WHERE user_id = :person_id'), {'person_id': person_id}).scalar() + if role == ROLE_ADMIN or role == ROLE_STAFF: + if src == SRC_API: + raise Exception('cannot_block_admin_or_staff') + else: + flash(_('You cannot block admin or staff.'), 'error') + return + + existing_block = UserBlock.query.filter_by(blocker_id=user_id, blocked_id=person_id).first() + if not existing_block: + block = UserBlock(blocker_id=user_id, blocked_id=person_id) + db.session.add(block) + db.session.execute(text('DELETE FROM "notification_subscription" WHERE entity_id = :current_user AND user_id = :user_id'), + {'current_user': user_id, 'user_id': person_id}) + db.session.commit() + + cache.delete_memoized(blocked_users, user_id) + + # Nothing to fed? (Lemmy doesn't federate anything to the blocked person) + + if src == SRC_API: + return user_id + else: + return # let calling function handle confirmation flash message and redirect + + +def unblock_another_user(person_id, src, auth=None): + if src == SRC_API: + try: + user_id = authorise_api_user(auth) + except: + raise + else: + user_id = current_user.id + + if user_id == person_id: + if src == SRC_API: + raise Exception('cannot_unblock_self') + else: + flash(_('You cannot unblock yourself.'), 'error') + return + + existing_block = UserBlock.query.filter_by(blocker_id=user_id, blocked_id=person_id).first() + if existing_block: + db.session.delete(existing_block) + db.session.commit() + + cache.delete_memoized(blocked_users, user_id) + + # Nothing to fed? (Lemmy doesn't federate anything to the unblocked person) + + if src == SRC_API: + return user_id + else: + return # let calling function handle confirmation flash message and redirect diff --git a/app/utils.py b/app/utils.py index f7d003ab..d865b126 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1282,6 +1282,8 @@ def add_to_modlog_activitypub(action: str, actor: User, community_id: int = None def authorise_api_user(auth, return_type='id'): + if not auth: + raise Exception('incorrect_login') token = auth[7:] # remove 'Bearer ' try: @@ -1294,7 +1296,7 @@ def authorise_api_user(auth, return_type='id'): if return_type == 'model': return user else: - return user_id + return user.id else: raise Exception('incorrect_login') except jwt.InvalidTokenError: