diff --git a/app/api/alpha/routes.py b/app/api/alpha/routes.py index 2020f4d9..fe7f9e2c 100644 --- a/app/api/alpha/routes.py +++ b/app/api/alpha/routes.py @@ -2,7 +2,7 @@ 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_community_list, get_community, post_community_follow, \ get_user, post_user_block from app.shared.auth import log_user_in @@ -46,6 +46,18 @@ 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 + + # Post @bp.route('/api/alpha/post/list', methods=['GET']) def get_alpha_post_list(): @@ -214,7 +226,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']) diff --git a/app/api/alpha/utils/__init__.py b/app/api/alpha/utils/__init__.py index 401e4785..da561671 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.community import get_community, get_community_list, post_community_follow 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 cdfcc8f1..b8463f3d 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 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 @@ -58,8 +60,8 @@ def get_community(auth, data): if auth: try: user_id = authorise_api_user(auth) - except Exception as e: - raise e + except: + raise else: user_id = None @@ -68,3 +70,36 @@ def get_community(auth, data): 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 diff --git a/app/api/alpha/views.py b/app/api/alpha/views.py index 71f98b57..b1b0dce0 100644 --- a/app/api/alpha/views.py +++ b/app/api/alpha/views.py @@ -226,6 +226,11 @@ def community_view(community: Community | int | str, variant, stub=False, user_i '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 # would be better to incrementally add to a post_reply.path field diff --git a/app/shared/community.py b/app/shared/community.py new file mode 100644 index 00000000..4f3bd6b1 --- /dev/null +++ b/app/shared/community.py @@ -0,0 +1,193 @@ +from app import db, cache +from app.activitypub.signature import post_request +from app.constants import * +from app.models import Community, CommunityBan, CommunityJoinRequest, CommunityMember +from app.utils import authorise_api_user, 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 + + + + + + + + + + + +