diff --git a/conversation.py b/conversation.py index c448ccd8e..bfc85d6fe 100644 --- a/conversation.py +++ b/conversation.py @@ -12,6 +12,10 @@ from utils import has_object_dict from utils import acct_dir from utils import remove_id_ending from utils import text_in_file +from utils import locate_post +from utils import load_json +from keys import get_instance_actor_key +from session import get_json def _get_conversation_filename(base_dir: str, nickname: str, domain: str, @@ -98,3 +102,88 @@ def unmute_conversation(base_dir: str, nickname: str, domain: str, except OSError: print('EX: unmute_conversation unable to delete ' + conversation_filename + '.muted') + + +def download_conversation_posts(session, http_prefix: str, base_dir: str, + nickname: str, domain: str, + post_id: str, debug: bool) -> []: + """Downloads all posts for a conversation and returns a list of the + json objects + """ + if '://' not in post_id: + return [] + profile_str = 'https://www.w3.org/ns/activitystreams' + as_header = { + 'Accept': 'application/ld+json; profile="' + profile_str + '"' + } + conversation_view = [] + signing_priv_key_pem = get_instance_actor_key(base_dir, domain) + post_id = remove_id_ending(post_id) + post_filename = \ + locate_post(base_dir, nickname, domain, post_id) + if post_filename: + post_json = load_json(post_filename) + else: + post_json = get_json(signing_priv_key_pem, session, post_id, + as_header, None, debug, __version__, + http_prefix, domain) + if debug: + if not post_json: + print(post_id + ' returned no json') + while post_json: + if not isinstance(post_json, dict): + break + if not has_object_dict(post_json): + if not post_json.get('attributedTo'): + print(str(post_json)) + if debug: + print(post_id + ' has no attributedTo') + break + if not isinstance(post_json['attributedTo'], str): + break + if not post_json.get('published'): + if debug: + print(post_id + ' has no published date') + break + if not post_json.get('to'): + if debug: + print(post_id + ' has no "to" list') + break + if not isinstance(post_json['to'], list): + break + if 'cc' not in post_json: + if debug: + print(post_id + ' has no "cc" list') + break + if not isinstance(post_json['cc'], list): + break + wrapped_post = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': post_id + '/activity', + 'type': 'Create', + 'actor': post_json['attributedTo'], + 'published': post_json['published'], + 'to': post_json['to'], + 'cc': post_json['cc'], + 'object': post_json + } + post_json = wrapped_post + conversation_view = [post_json] + conversation_view + if not post_json['object'].get('inReplyTo'): + if debug: + print(post_id + ' is not a reply') + break + post_id = post_json['object']['inReplyTo'] + post_id = remove_id_ending(post_id) + post_filename = \ + locate_post(base_dir, nickname, domain, post_id) + if post_filename: + post_json = load_json(post_filename) + else: + post_json = get_json(signing_priv_key_pem, session, post_id, + as_header, None, debug, __version__, + http_prefix, domain) + if debug: + if not post_json: + print(post_id + ' returned no json') + return conversation_view diff --git a/daemon.py b/daemon.py index bd728dd10..bc2599767 100644 --- a/daemon.py +++ b/daemon.py @@ -83,6 +83,7 @@ from person import remove_account from person import can_remove_post from person import person_snooze from person import person_unsnooze +from keys import get_instance_actor_key from posts import get_max_profile_posts from posts import set_max_profile_posts from posts import get_post_expiry_keep_dms @@ -91,7 +92,6 @@ from posts import get_post_expiry_days from posts import set_post_expiry_days from posts import get_original_post_from_announce_url from posts import save_post_to_box -from posts import get_instance_actor_key from posts import remove_post_interactions from posts import outbox_message_create_wrap from posts import get_pinned_post_as_json @@ -221,7 +221,7 @@ from webapp_suspended import html_suspended from webapp_tos import html_terms_of_service from webapp_confirm import html_confirm_follow from webapp_confirm import html_confirm_unfollow -from webapp_post import html_conversation_thread +from webapp_conversation import html_conversation_view from webapp_post import html_emoji_reaction_picker from webapp_post import html_post_replies from webapp_post import html_individual_post @@ -11834,34 +11834,34 @@ class PubServer(BaseHTTPRequestHandler): if self.server.bold_reading.get(nickname): bold_reading = True conv_str = \ - html_conversation_thread(post_id, self.server.translate, - base_dir, - http_prefix, - nickname, - domain, - self.server.project_version, - self.server.recent_posts_cache, - self.server.max_recent_posts, - curr_session, - self.server.cached_webfingers, - self.server.person_cache, - port, - self.server.yt_replace_domain, - self.server.twitter_replacement_domain, - self.server.show_published_date_only, - self.server.peertube_instances, - self.server.allow_local_network_access, - self.server.theme_name, - self.server.system_language, - self.server.max_like_count, - self.server.signing_priv_key_pem, - self.server.cw_lists, - self.server.lists_enabled, - timezone, bold_reading, - self.server.dogwhistles, - self.server.access_keys, - self.server.min_images_for_accounts, - self.server.debug) + html_conversation_view(post_id, self.server.translate, + base_dir, + http_prefix, + nickname, + domain, + self.server.project_version, + self.server.recent_posts_cache, + self.server.max_recent_posts, + curr_session, + self.server.cached_webfingers, + self.server.person_cache, + port, + self.server.yt_replace_domain, + self.server.twitter_replacement_domain, + self.server.show_published_date_only, + self.server.peertube_instances, + self.server.allow_local_network_access, + self.server.theme_name, + self.server.system_language, + self.server.max_like_count, + self.server.signing_priv_key_pem, + self.server.cw_lists, + self.server.lists_enabled, + timezone, bold_reading, + self.server.dogwhistles, + self.server.access_keys, + self.server.min_images_for_accounts, + self.server.debug) if conv_str: msg = conv_str.encode('utf-8') msglen = len(msg) diff --git a/epicyon.py b/epicyon.py index a5871e25e..47ef8ee1a 100644 --- a/epicyon.py +++ b/epicyon.py @@ -26,9 +26,9 @@ from roles import set_role from webfinger import webfinger_handle from bookmarks import send_bookmark_via_server from bookmarks import send_undo_bookmark_via_server -from posts import download_conversation_posts +from conversation import download_conversation_posts +from keys import get_instance_actor_key from posts import set_post_expiry_days -from posts import get_instance_actor_key from posts import send_mute_via_server from posts import send_undo_mute_via_server from posts import c2s_box_json diff --git a/keys.py b/keys.py new file mode 100644 index 000000000..84bc6b3c5 --- /dev/null +++ b/keys.py @@ -0,0 +1,64 @@ +__filename__ = "keys.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.3.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@libreserver.org" +__status__ = "Production" +__module_group__ = "ActivityPub" + +import os + + +def _get_local_private_key(base_dir: str, nickname: str, domain: str) -> str: + """Returns the private key for a local account + """ + if not domain or not nickname: + return None + handle = nickname + '@' + domain + key_filename = base_dir + '/keys/private/' + handle.lower() + '.key' + if not os.path.isfile(key_filename): + return None + with open(key_filename, 'r', encoding='utf-8') as pem_file: + return pem_file.read() + return None + + +def _get_local_public_key(base_dir: str, nickname: str, domain: str) -> str: + """Returns the public key for a local account + """ + if not domain or not nickname: + return None + handle = nickname + '@' + domain + key_filename = base_dir + '/keys/public/' + handle.lower() + '.key' + if not os.path.isfile(key_filename): + return None + with open(key_filename, 'r', encoding='utf-8') as pem_file: + return pem_file.read() + return None + + +def get_instance_actor_key(base_dir: str, domain: str) -> str: + """Returns the private key for the instance actor used for + signing GET posts + """ + return _get_local_private_key(base_dir, 'inbox', domain) + + +def get_person_key(nickname: str, domain: str, base_dir: str, + key_type: str = 'public', debug: bool = False): + """Returns the public or private key of a person + """ + if key_type == 'private': + key_pem = _get_local_private_key(base_dir, nickname, domain) + else: + key_pem = _get_local_public_key(base_dir, nickname, domain) + if not key_pem: + if debug: + print('DEBUG: ' + key_type + ' key file not found') + return '' + if len(key_pem) < 20: + if debug: + print('DEBUG: private key was too short: ' + key_pem) + return '' + return key_pem diff --git a/posts.py b/posts.py index 6c9beeb80..c27a7a0a1 100644 --- a/posts.py +++ b/posts.py @@ -101,6 +101,7 @@ from petnames import resolve_petnames from video import convert_video_to_note from context import get_individual_post_context from maps import geocoords_from_map_link +from keys import get_person_key def is_moderator(base_dir: str, nickname: str) -> bool: @@ -151,60 +152,6 @@ def no_of_followers_on_domain(base_dir: str, handle: str, return ctr -def _get_local_private_key(base_dir: str, nickname: str, domain: str) -> str: - """Returns the private key for a local account - """ - if not domain or not nickname: - return None - handle = nickname + '@' + domain - key_filename = base_dir + '/keys/private/' + handle.lower() + '.key' - if not os.path.isfile(key_filename): - return None - with open(key_filename, 'r', encoding='utf-8') as pem_file: - return pem_file.read() - return None - - -def get_instance_actor_key(base_dir: str, domain: str) -> str: - """Returns the private key for the instance actor used for - signing GET posts - """ - return _get_local_private_key(base_dir, 'inbox', domain) - - -def _get_local_public_key(base_dir: str, nickname: str, domain: str) -> str: - """Returns the public key for a local account - """ - if not domain or not nickname: - return None - handle = nickname + '@' + domain - key_filename = base_dir + '/keys/public/' + handle.lower() + '.key' - if not os.path.isfile(key_filename): - return None - with open(key_filename, 'r', encoding='utf-8') as pem_file: - return pem_file.read() - return None - - -def _get_person_key(nickname: str, domain: str, base_dir: str, - key_type: str = 'public', debug: bool = False): - """Returns the public or private key of a person - """ - if key_type == 'private': - key_pem = _get_local_private_key(base_dir, nickname, domain) - else: - key_pem = _get_local_public_key(base_dir, nickname, domain) - if not key_pem: - if debug: - print('DEBUG: ' + key_type + ' key file not found') - return '' - if len(key_pem) < 20: - if debug: - print('DEBUG: private key was too short: ' + key_pem) - return '' - return key_pem - - def _clean_html(raw_html: str) -> str: # text=BeautifulSoup(raw_html, 'html.parser').get_text() text = raw_html @@ -2532,7 +2479,7 @@ def send_post(signing_priv_key_pem: str, project_version: str, translate) # get the senders private key - private_key_pem = _get_person_key(nickname, domain, base_dir, 'private') + private_key_pem = get_person_key(nickname, domain, base_dir, 'private') if len(private_key_pem) == 0: return 6 @@ -2937,7 +2884,7 @@ def send_signed_json(post_json_object: {}, session, base_dir: str, if account_domain == i2p_domain: account_domain = curr_domain private_key_pem = \ - _get_person_key(nickname, account_domain, base_dir, 'private', debug) + get_person_key(nickname, account_domain, base_dir, 'private', debug) if len(private_key_pem) == 0: if debug: print('DEBUG: Private key not found for ' + @@ -6094,86 +6041,3 @@ def set_max_profile_posts(base_dir: str, nickname: str, domain: str, max_posts_filename) return False return True - - -def download_conversation_posts(session, http_prefix: str, base_dir: str, - nickname: str, domain: str, - post_id: str, debug: bool) -> []: - """Downloads all posts for a conversation and returns a list of the - json objects - """ - if '://' not in post_id: - return [] - profile_str = 'https://www.w3.org/ns/activitystreams' - as_header = { - 'Accept': 'application/ld+json; profile="' + profile_str + '"' - } - conversation_thread = [] - signing_priv_key_pem = get_instance_actor_key(base_dir, domain) - post_id = remove_id_ending(post_id) - post_filename = \ - locate_post(base_dir, nickname, domain, post_id) - if post_filename: - post_json = load_json(post_filename) - else: - post_json = get_json(signing_priv_key_pem, session, post_id, - as_header, None, debug, __version__, - http_prefix, domain) - if debug: - if not post_json: - print(post_id + ' returned no json') - while post_json: - if not has_object_dict(post_json): - if not post_json.get('attributedTo'): - print(str(post_json)) - if debug: - print(post_id + ' has no attributedTo') - break - if not isinstance(post_json['attributedTo'], str): - break - if not post_json.get('published'): - if debug: - print(post_id + ' has no published date') - break - if not post_json.get('to'): - if debug: - print(post_id + ' has no "to" list') - break - if not isinstance(post_json['to'], list): - break - if 'cc' not in post_json: - if debug: - print(post_id + ' has no "cc" list') - break - if not isinstance(post_json['cc'], list): - break - wrapped_post = { - "@context": "https://www.w3.org/ns/activitystreams", - 'id': post_id + '/activity', - 'type': 'Create', - 'actor': post_json['attributedTo'], - 'published': post_json['published'], - 'to': post_json['to'], - 'cc': post_json['cc'], - 'object': post_json - } - post_json = wrapped_post - conversation_thread = [post_json] + conversation_thread - if not post_json['object'].get('inReplyTo'): - if debug: - print(post_id + ' is not a reply') - break - post_id = post_json['object']['inReplyTo'] - post_id = remove_id_ending(post_id) - post_filename = \ - locate_post(base_dir, nickname, domain, post_id) - if post_filename: - post_json = load_json(post_filename) - else: - post_json = get_json(signing_priv_key_pem, session, post_id, - as_header, None, debug, __version__, - http_prefix, domain) - if debug: - if not post_json: - print(post_id + ' returned no json') - return conversation_thread diff --git a/tests.py b/tests.py index 750fbbfa9..58f51b6a9 100644 --- a/tests.py +++ b/tests.py @@ -1400,7 +1400,7 @@ def test_post_message_between_servers(base_dir: str) -> None: assert 'यह एक परीक्षण है' in received_json['object']['content'] print('Check that message received from Alice contains an attachment') assert received_json['object']['attachment'] - assert len(received_json['object']['attachment']) == 1 + assert len(received_json['object']['attachment']) == 2 attached = received_json['object']['attachment'][0] pprint(attached) assert attached.get('type') diff --git a/webapp_conversation.py b/webapp_conversation.py new file mode 100644 index 000000000..d56b8d6bb --- /dev/null +++ b/webapp_conversation.py @@ -0,0 +1,104 @@ +__filename__ = "conversation.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.3.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@libreserver.org" +__status__ = "Production" +__module_group__ = "Timeline" + + +import os +from conversation import download_conversation_posts +from utils import get_config_param +from webapp_utils import html_header_with_external_style +from webapp_utils import html_post_separator +from webapp_utils import html_footer +from webapp_post import individual_post_as_html + + +def html_conversation_view(post_id: str, + translate: {}, base_dir: str, + http_prefix: str, + nickname: str, domain: str, + project_version: str, + recent_posts_cache: {}, + max_recent_posts: int, + session, + cached_webfingers, + person_cache: {}, + port: int, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, + system_language: str, + max_like_count: int, + signing_priv_key_pem: str, + cw_lists: {}, + lists_enabled: str, + timezone: str, bold_reading: bool, + dogwhistles: {}, access_keys: {}, + min_images_for_accounts: [], + debug: bool) -> str: + """Show a page containing a conversation thread + """ + conv_posts = \ + download_conversation_posts(session, http_prefix, base_dir, + nickname, domain, + post_id, debug) + + if not conv_posts: + return None + + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' + + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + conv_str = \ + html_header_with_external_style(css_filename, instance_title, None) + + separator_str = html_post_separator(base_dir, None) + text_mode_separator = '

' + + minimize_all_images = False + if nickname in min_images_for_accounts: + minimize_all_images = True + for post_json_object in conv_posts: + show_individual_post_icons = False + allow_deletion = False + post_str = \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, cached_webfingers, + person_cache, + nickname, domain, port, + post_json_object, + None, True, allow_deletion, + http_prefix, project_version, + 'search', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme_name, system_language, + max_like_count, + show_individual_post_icons, + show_individual_post_icons, + False, False, False, False, + cw_lists, lists_enabled, + timezone, False, bold_reading, + dogwhistles, + minimize_all_images) + if post_str: + conv_str += text_mode_separator + separator_str + post_str + + conv_str += text_mode_separator + html_footer() + return conv_str diff --git a/webapp_post.py b/webapp_post.py index 75ff621d1..8aa5fcc8b 100644 --- a/webapp_post.py +++ b/webapp_post.py @@ -21,7 +21,6 @@ from announce import no_of_announces from like import liked_by_person from like import no_of_likes from follow import is_following_actor -from posts import download_conversation_posts from posts import post_is_muted from posts import get_person_box from posts import download_announce @@ -93,7 +92,6 @@ from webapp_utils import get_post_attachments_as_html from webapp_utils import html_header_with_external_style from webapp_utils import html_footer from webapp_utils import get_broken_link_substitute -from webapp_utils import html_post_separator from webapp_media import add_embedded_elements from webapp_question import insert_question from devices import e2e_edecrypt_message_from_device @@ -2922,90 +2920,3 @@ def html_emoji_reaction_picker(recent_posts_cache: {}, max_recent_posts: int, '\n' return header_str + reacted_to_post_str + emoji_picks_str + html_footer() - - -def html_conversation_thread(post_id: str, - translate: {}, base_dir: str, - http_prefix: str, - nickname: str, domain: str, - project_version: str, - recent_posts_cache: {}, - max_recent_posts: int, - session, - cached_webfingers, - person_cache: {}, - port: int, - yt_replace_domain: str, - twitter_replacement_domain: str, - show_published_date_only: bool, - peertube_instances: [], - allow_local_network_access: bool, - theme_name: str, - system_language: str, - max_like_count: int, - signing_priv_key_pem: str, - cw_lists: {}, - lists_enabled: str, - timezone: str, bold_reading: bool, - dogwhistles: {}, access_keys: {}, - min_images_for_accounts: [], - debug: bool) -> str: - """Show a page containing a conversation thread - """ - conv_posts = \ - download_conversation_posts(session, http_prefix, base_dir, - nickname, domain, - post_id, debug) - - if not conv_posts: - return None - - css_filename = base_dir + '/epicyon-profile.css' - if os.path.isfile(base_dir + '/epicyon.css'): - css_filename = base_dir + '/epicyon.css' - - instance_title = \ - get_config_param(base_dir, 'instanceTitle') - conv_str = \ - html_header_with_external_style(css_filename, instance_title, None) - - separator_str = html_post_separator(base_dir, None) - text_mode_separator = '

' - - minimize_all_images = False - if nickname in min_images_for_accounts: - minimize_all_images = True - for post_json_object in conv_posts: - show_individual_post_icons = False - allow_deletion = False - post_str = \ - individual_post_as_html(signing_priv_key_pem, - True, recent_posts_cache, - max_recent_posts, - translate, None, - base_dir, session, cached_webfingers, - person_cache, - nickname, domain, port, - post_json_object, - None, True, allow_deletion, - http_prefix, project_version, - 'search', - yt_replace_domain, - twitter_replacement_domain, - show_published_date_only, - peertube_instances, - allow_local_network_access, - theme_name, system_language, - max_like_count, - show_individual_post_icons, - show_individual_post_icons, - False, False, False, False, - cw_lists, lists_enabled, - timezone, False, bold_reading, - dogwhistles, - minimize_all_images) - if post_str: - conv_str += text_mode_separator + separator_str + post_str - - conv_str += text_mode_separator + html_footer() - return conv_str