__filename__ = "webapp_profile.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.5.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" import os from pprint import pprint from webfinger import webfinger_handle from utils import data_dir from utils import is_premium_account from utils import time_days_ago from utils import uninvert_text from utils import get_attributed_to from utils import get_url_from_post from utils import get_memorials from utils import text_in_file from utils import dangerous_markup from utils import ap_proxy_type from utils import remove_id_ending from utils import standardize_text from utils import get_display_name from utils import is_group_account from utils import has_object_dict from utils import get_occupation_name from utils import get_locked_account from utils import get_full_domain from utils import is_artist from utils import is_dormant from utils import get_nickname_from_actor from utils import get_domain_from_actor from utils import is_system_account from utils import remove_html from utils import load_json from utils import get_config_param from utils import get_image_formats from utils import acct_dir from utils import get_supported_languages from utils import local_actor_url from utils import get_reply_interval_hours from utils import get_account_timezone from utils import remove_eol from utils import is_valid_date from utils import get_actor_from_post from utils import resembles_url from languages import get_actor_languages from skills import get_skills from theme import get_themes_list from person import get_featured_hashtags_as_html from person import get_featured_hashtags from person import person_box_json from person import get_actor_json from person import get_person_avatar_url from person import get_person_notes from posts import get_post_expiry_keep_dms from posts import get_post_expiry_days from posts import get_person_box from posts import is_moderator from posts import parse_user_feed from posts import is_create_inside_announce from posts import get_max_profile_posts from donate import get_donation_url from donate import get_website from donate import get_gemini_link from xmpp import get_xmpp_address from matrix import get_matrix_address from ssb import get_ssb_address from pgp import get_email_address from pgp import get_pgp_fingerprint from pgp import get_pgp_pub_key from enigma import get_enigma_pub_key from tox import get_tox_address from briar import get_briar_address from cwtch import get_cwtch_address from filters import is_filtered from follow import is_follower_of_person from follow import get_follower_domains from follow import is_following_actor from webapp_frontscreen import html_front_screen from webapp_utils import html_following_dropdown from webapp_utils import edit_number_field from webapp_utils import html_keyboard_navigation from webapp_utils import html_hide_from_screen_reader from webapp_utils import scheduled_posts_exist from webapp_utils import html_header_with_external_style from webapp_utils import html_header_with_person_markup from webapp_utils import html_footer from webapp_utils import add_emoji_to_display_name from webapp_utils import get_profile_background_file from webapp_utils import html_post_separator from webapp_utils import edit_check_box from webapp_utils import edit_text_field from webapp_utils import edit_text_area from webapp_utils import begin_edit_section from webapp_utils import end_edit_section from blog import get_blog_address from blog import account_has_blog from webapp_post import individual_post_as_html from webapp_timeline import html_individual_share from webapp_timeline import page_number_buttons from cwlists import get_cw_list_variable from blocking import get_account_blocks from blocking import is_blocked from content import remove_link_trackers_from_content from content import bold_reading_string from roles import is_devops from session import get_json_valid from session import site_is_verified from session import get_json from shares import actor_attached_shares_as_html from git import get_repo_url from reading import html_profile_book_list THEME_FORMATS = '.zip, .gz' BLOCKFILE_FORMATS = '.csv' def _import_export_blocks(translate: {}) -> str: """ import and export blocks on edit profile screen """ edit_profile_form = \ ' \n' edit_profile_form += ' \n' edit_profile_form += \ '
\n' edit_profile_form += \ '
\n' return edit_profile_form def _get_profile_short_description(profile_description: str) -> str: """Returns a short version of the profile description """ profile_description_short = profile_description if '\n' in profile_description: if len(profile_description.split('\n')) > 6: profile_description_short = '' else: if '
' in profile_description: if len(profile_description.split('
')) > 6: profile_description_short = '' # keep the profile description short if len(profile_description_short) > 2048: profile_description_short = profile_description_short[:2048] return profile_description_short def _valid_profile_preview_post(post_json_object: {}, person_url: str) -> (bool, {}): """Returns true if the given post should appear on a person/group profile after searching for a handle """ if not isinstance(post_json_object, dict): return False, None is_announced_feed_item = False if is_create_inside_announce(post_json_object): is_announced_feed_item = True post_json_object = post_json_object['object'] if not post_json_object.get('type'): return False, None if post_json_object['type'] == 'Create': if not has_object_dict(post_json_object): return False, None if post_json_object['type'] not in ('Create', 'Announce'): if post_json_object['type'] not in ('Note', 'Event', 'Video', 'Page'): return False, None if not post_json_object.get('to'): return False, None if not post_json_object.get('id'): return False, None # wrap in create cc_list = [] if post_json_object.get('cc'): cc_list = post_json_object['cc'] new_post_json_object = { 'object': post_json_object, 'to': post_json_object['to'], 'cc': cc_list, 'id': post_json_object['id'], 'actor': person_url, 'type': 'Create' } post_json_object = new_post_json_object if not post_json_object.get('actor'): return False, None # convert actor back to id if isinstance(post_json_object['actor'], dict): actor_url = get_actor_from_post(post_json_object) if actor_url: post_json_object['actor'] = actor_url if has_object_dict(post_json_object): # convert attributedTo actor back to id if post_json_object['object'].get('attributedTo'): attrib_field = post_json_object['object']['attributedTo'] if attrib_field: if isinstance(attrib_field, dict): attrib = get_attributed_to(attrib_field) if attrib: post_json_object['object']['attributedTo'] = attrib if not is_announced_feed_item: actor_url = get_actor_from_post(post_json_object) if actor_url != person_url and \ post_json_object['object']['type'] != 'Page': return False, None return True, post_json_object def html_profile_after_search(authorized: bool, recent_posts_cache: {}, max_recent_posts: int, translate: {}, base_dir: str, path: str, http_prefix: str, nickname: str, domain: str, port: int, profile_handle: str, session, cached_webfingers: {}, person_cache: {}, debug: bool, project_version: str, yt_replace_domain: str, twitter_replacement_domain: str, show_published_date_only: bool, default_timeline: str, peertube_instances: [], allow_local_network_access: bool, theme_name: str, access_keys: {}, system_language: str, max_like_count: int, signing_priv_key_pem: str, cw_lists: {}, lists_enabled: str, timezone: str, onion_domain: str, i2p_domain: str, bold_reading: bool, dogwhistles: {}, min_images_for_accounts: [], buy_sites: {}, max_shares_on_profile: int, no_of_books: int, auto_cw_cache: {}) -> str: """Show a profile page after a search for a fediverse address """ http = False gnunet = False ipfs = False ipns = False if http_prefix == 'http': http = True elif http_prefix == 'gnunet': gnunet = True elif http_prefix == 'ipfs': ipfs = True elif http_prefix == 'ipns': ipns = True from_domain = domain if onion_domain: if '.onion/' in profile_handle or profile_handle.endswith('.onion'): from_domain = onion_domain http = True if i2p_domain: if '.i2p/' in profile_handle or profile_handle.endswith('.i2p'): from_domain = i2p_domain http = True profile_json, as_header = \ get_actor_json(from_domain, profile_handle, http, gnunet, ipfs, ipns, debug, False, signing_priv_key_pem, session) if not profile_json: return None if not profile_json.get('id'): return None person_url = profile_json['id'] search_domain, search_port = get_domain_from_actor(person_url) if not search_domain: return None search_nickname = get_nickname_from_actor(person_url) if not search_nickname: return None search_domain_full = get_full_domain(search_domain, search_port) profile_str = '' css_filename = base_dir + '/epicyon-profile.css' if os.path.isfile(base_dir + '/epicyon.css'): css_filename = base_dir + '/epicyon.css' is_group = False if profile_json.get('type'): if profile_json['type'] == 'Group': is_group = True # shared items attached to the actor # https://codeberg.org/fediverse/fep/src/branch/main/fep/0837/fep-0837.md attached_shared_items = \ actor_attached_shares_as_html(profile_json, max_shares_on_profile) avatar_url = '' if profile_json.get('icon'): icon_dict = profile_json['icon'] if isinstance(icon_dict, list): if len(icon_dict) > 0: icon_dict = icon_dict[-1] if isinstance(icon_dict, dict): if icon_dict.get('url'): url_str = get_url_from_post(icon_dict['url']) avatar_url = remove_html(url_str) if not avatar_url: avatar_url = get_person_avatar_url(base_dir, person_url, person_cache) display_name = search_nickname if profile_json.get('name'): display_name = profile_json['name'] display_name = remove_html(display_name) display_name = uninvert_text(display_name) display_name = \ add_emoji_to_display_name(session, base_dir, http_prefix, nickname, domain, display_name, False, translate) locked_account = get_locked_account(profile_json) if locked_account: display_name += '🔒' moved_to = '' if profile_json.get('movedTo'): moved_to = profile_json['movedTo'] if '"' in moved_to: moved_to = moved_to.split('"')[1] moved_to = remove_html(moved_to) display_name += ' ⌂' you_follow = \ is_following_actor(base_dir, nickname, domain, person_url) follows_you = \ is_follower_of_person(base_dir, nickname, domain, search_nickname, search_domain_full) profile_description = '' if profile_json.get('summary'): if not dangerous_markup(profile_json['summary'], False, []): profile_description = profile_json['summary'] else: profile_description = remove_html(profile_json['summary']) profile_description = \ remove_link_trackers_from_content(profile_description) profile_description = \ add_emoji_to_display_name(session, base_dir, http_prefix, nickname, domain, profile_description, False, translate) featured_hashtags = \ get_featured_hashtags_as_html(profile_json, profile_description) outbox_url = None if not profile_json.get('outbox'): if debug: pprint(profile_json) print('DEBUG: No outbox found') return None outbox_url = profile_json['outbox'] # profileBackgroundImage = '' # if profile_json.get('image'): # if profile_json['image'].get('url'): # url_str = get_url_from_post(profile_json['image']['url']) # profileBackgroundImage = remove_html(url_str) # url to return to back_url = path if not back_url.endswith('/inbox'): back_url += '/inbox' profile_description_short = \ _get_profile_short_description(profile_description) # remove formatting from profile description used on title avatar_description = '' if profile_json.get('summary'): if isinstance(profile_json['summary'], str): avatar_description = \ profile_json['summary'].replace('
', '\n') avatar_description = avatar_description.replace('

', '') avatar_description = avatar_description.replace('

', '') if '<' in avatar_description: avatar_description = remove_html(avatar_description) image_url = '' if profile_json.get('image'): if profile_json['image'].get('url'): url_str = get_url_from_post(profile_json['image']['url']) image_url = remove_html(url_str) also_known_as = None if profile_json.get('alsoKnownAs'): also_known_as = remove_html(profile_json['alsoKnownAs']) elif profile_json.get('sameAs'): also_known_as = remove_html(profile_json['sameAs']) joined_date = None if profile_json.get('published'): if 'T' in profile_json['published']: joined_date = remove_html(profile_json['published']) actor_proxied = ap_proxy_type(profile_json) website_url = get_website(profile_json, translate) repo_url = get_repo_url(profile_json) # is sending posts to this account blocked? send_block_filename = \ acct_dir(base_dir, nickname, domain) + '/send_blocks.txt' send_blocks_str = '' if os.path.isfile(send_block_filename): if text_in_file(person_url, send_block_filename, False): send_blocks_str = translate['FollowAccountWarning'] elif text_in_file('://' + search_domain_full + '\n', send_block_filename, False): send_blocks_str = translate['FollowWarning'] birth_date = '' if profile_json.get('vcard:bday'): birth_date = profile_json['vcard:bday'] profile_str = \ _get_profile_header_after_search(base_dir, nickname, domain, default_timeline, search_nickname, search_domain_full, translate, display_name, you_follow, follows_you, profile_description_short, featured_hashtags, avatar_url, image_url, moved_to, profile_json['id'], also_known_as, access_keys, joined_date, actor_proxied, attached_shared_items, website_url, repo_url, send_blocks_str, authorized, person_url, no_of_books, birth_date) domain_full = get_full_domain(domain, port) follow_is_permitted = True if not profile_json.get('followers'): # no followers collection specified within actor follow_is_permitted = False elif search_nickname == 'news' and search_domain_full == domain_full: # currently the news actor is not something you can follow follow_is_permitted = False elif search_nickname == nickname and search_domain_full == domain_full: # don't follow yourself! follow_is_permitted = False blocked = \ is_blocked(base_dir, nickname, domain, search_nickname, search_domain, None, None) if follow_is_permitted: follow_str = 'Follow' if is_group: follow_str = 'Join' profile_str += \ '
\n' + \ '
\n' + \ '
\n' profile_str += \ ' \n' if not you_follow: if is_moderator(base_dir, nickname): profile_str += \ ' \n' profile_str += \ ' \n' profile_str += \ ' \n' if blocked: profile_str += \ ' \n' profile_str += \ '
\n' + \ '
\n' + \ '
\n' else: profile_str += \ '
\n' + \ '
\n' + \ '
\n' + \ ' \n' + \ ' \n' + \ '
\n' + \ '
\n' + \ '
\n' text_mode_separator = '

' user_feed = \ parse_user_feed(signing_priv_key_pem, session, outbox_url, as_header, project_version, http_prefix, from_domain, debug, 0) if not user_feed: if debug: print('DEBUG: no user feed in profile preview') else: minimize_all_images = False if nickname in min_images_for_accounts: minimize_all_images = True i = 0 for item in user_feed: if item.get('type') and item.get('object'): if str(item['type']) == 'Announce' and \ isinstance(item['object'], str): # resolve the announce profile_str2 = 'https://www.w3.org/ns/activitystreams' as_header2_str = 'application/ld+json; profile="' + \ profile_str2 + '"' as_header2 = { 'Accept': as_header2_str } item = \ get_json(signing_priv_key_pem, session, item['object'], as_header2, None, debug, __version__, http_prefix, from_domain) if debug: print('DEBUG: resolved public feed announce ' + str(item)) if not get_json_valid(item): if debug: print('DEBUG: ' + 'announce json is not valid in timeline ' + str(item)) continue show_item, post_json_object = \ _valid_profile_preview_post(item, person_url) if not show_item: if debug: print('DEBUG: item not valid in profile posts: ' + str(item)) continue profile_post_html = \ 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, avatar_url, False, False, http_prefix, project_version, 'inbox', yt_replace_domain, twitter_replacement_domain, show_published_date_only, peertube_instances, allow_local_network_access, theme_name, system_language, max_like_count, False, False, False, False, False, False, cw_lists, lists_enabled, timezone, False, bold_reading, dogwhistles, minimize_all_images, None, buy_sites, auto_cw_cache) if not profile_post_html: if debug: print('DEBUG: no html produced for profile post: ' + str(item)) continue profile_str += text_mode_separator + profile_post_html i += 1 if i >= 8: break instance_title = get_config_param(base_dir, 'instanceTitle') return html_header_with_external_style(css_filename, instance_title, None) + \ profile_str + text_mode_separator + html_footer() def _profile_shared_items_list(attached_shared_items: str, translate: {}) -> str: """Complete html for shared items shown on profile """ return \ '

' + translate['Shares'] + ':
\n' + \ attached_shared_items + '

\n' def _get_profile_header(base_dir: str, http_prefix: str, nickname: str, domain: str, domain_full: str, translate: {}, default_timeline: str, display_name: str, profile_description_short: str, featured_hashtags: str, login_button: str, avatar_url: str, theme: str, moved_to: str, also_known_as: [], pinned_content: str, attached_shared_items: str, access_keys: {}, joined_date: str, occupation_name: str, actor_proxied: str, person_url: str, no_of_books: int, authorized: bool, birth_date: str, premium: bool) -> str: """The header of the profile screen, containing background image and avatar """ banner_file, _ = \ get_profile_background_file(base_dir, nickname, domain, theme) html_str = \ '\n\n
\n' + \ ' \n' + \ ' \n' + \ '
\n' + \ ' \n' + \ ' \n' occupation_str = '' if occupation_name: occupation_str += \ ' ' + occupation_name + '
\n' html_str += '

' + display_name + '\n

\n' if premium: html_str += \ ' ' + translate['Premium account'] + '
\n' html_str += occupation_str # show if the actor is proxied if not actor_proxied: actor_proxied = '' else: actor_proxied = remove_html(actor_proxied) if resembles_url(actor_proxied): proxy_str = 'Proxy' if translate.get(proxy_str): proxy_str = translate[proxy_str] actor_proxied = '' + \ proxy_str + '' elif '/' in actor_proxied: actor_proxied = actor_proxied.split('/')[-1] actor_proxied = ' [' + actor_proxied + ']' # show blog icon if this account has a blog acct_blog_str = '' has_blog = account_has_blog(base_dir, nickname, domain) if has_blog: acct_blog_str = \ ' 📖' html_str += \ '

@' + nickname + '@' + domain_full + \ actor_proxied + acct_blog_str + '
\n' if joined_date: joined_str = translate['Joined'] if time_days_ago(joined_date) < 7: joined_str = '' + translate['New account'] + '' html_str += \ '

' + joined_str + ' ' + \ joined_date.split('T')[0] + '
\n' if moved_to: new_nickname = get_nickname_from_actor(moved_to) new_domain, new_port = get_domain_from_actor(moved_to) if new_nickname and new_domain: new_domain_full = get_full_domain(new_domain, new_port) html_str += \ '

' + translate['New account'] + ': ' + \ '@' + \ new_nickname + '@' + new_domain_full + '
\n' elif also_known_as: other_accounts_html = \ '

' + translate['Other accounts'] + ': ' actor = local_actor_url(http_prefix, nickname, domain_full) ctr = 0 if isinstance(also_known_as, list): for alt_actor in also_known_as: if alt_actor == actor: continue if ctr > 0: other_accounts_html += ' ' ctr += 1 alt_domain, _ = get_domain_from_actor(alt_actor) if alt_domain: other_accounts_html += \ '' + alt_domain + '' elif isinstance(also_known_as, str): if also_known_as != actor: ctr += 1 alt_domain, _ = get_domain_from_actor(also_known_as) if alt_domain: other_accounts_html += \ '' + \ alt_domain + '' other_accounts_html += '

\n' if ctr > 0: html_str += other_accounts_html if is_valid_date(birth_date): birth_date = remove_html(birth_date) html_str += \ '

' + translate['Birthday'] + ': ' + \ birth_date + '

\n' if featured_hashtags: featured_hashtags += '\n' html_str += \ ' ' + \ '' + translate['QR Code'] + \
        '

\n' + \ '

' + profile_description_short + '

\n' + \ featured_hashtags + login_button if pinned_content: html_str += pinned_content.replace('

', '

📎', 1) if attached_shared_items: html_str += \ _profile_shared_items_list(attached_shared_items, translate) # show vcard download link html_str += \ ' ' + \ 'vCard\n' html_str += \ '

\n' + \ '
\n\n' # book events for this actor html_str += html_profile_book_list(base_dir, person_url, no_of_books, translate, nickname, domain, authorized) return html_str def _get_profile_header_after_search(base_dir: str, nickname: str, domain: str, default_timeline: str, search_nickname: str, search_domain_full: str, translate: {}, display_name: str, you_follow: bool, follows_you: bool, profile_description_short: str, featured_hashtags: str, avatar_url: str, image_url: str, moved_to: str, actor: str, also_known_as: [], access_keys: {}, joined_date: str, actor_proxied: str, attached_shared_items: str, website_url: str, repo_url: str, send_blocks_str: str, authorized: bool, person_url: str, no_of_books: str, birth_date: str) -> str: """The header of a searched for handle, containing background image and avatar """ if not image_url: image_url = '/defaultprofilebackground' html_str = \ '\n\n
\n' + \ ' \n' + \ ' \n' + \ '
\n' if avatar_url: html_str += \ ' \n' + \ ' \n' if not display_name: display_name = search_nickname if not actor_proxied: actor_proxied = '' else: actor_proxied = remove_html(actor_proxied) if resembles_url(actor_proxied): proxy_str = 'Proxy' if translate.get(proxy_str): proxy_str = translate[proxy_str] actor_proxied = '' + \ proxy_str + '' elif '/' in actor_proxied: actor_proxied = actor_proxied.split('/')[-1] actor_proxied = ' [' + actor_proxied + ']' html_str += \ '

\n' + \ ' ' + display_name + '\n' + \ '

\n' + \ '

@' + search_nickname + '@' + search_domain_full + \ actor_proxied + '
\n' if joined_date: joined_str = translate['Joined'] if time_days_ago(joined_date) < 7: joined_str = '' + translate['New account'] + '' html_str += '

' + joined_str + ' ' + \ joined_date.split('T')[0] + '

\n' if follows_you: if not you_follow: html_str += '

' + \ translate['Follows you'] + '

\n' else: html_str += '

' + \ translate['Mutuals'] + '

\n' if send_blocks_str: html_str += '

' + send_blocks_str + '

\n' if moved_to: new_nickname = get_nickname_from_actor(moved_to) new_domain, new_port = get_domain_from_actor(moved_to) if new_nickname and new_domain: new_domain_full = get_full_domain(new_domain, new_port) new_handle = new_nickname + '@' + new_domain_full html_str += '

' + translate['New account'] + \ ': @' + new_handle + '

\n' elif also_known_as: other_accounts_html = \ '

' + translate['Other accounts'] + ': ' ctr = 0 if isinstance(also_known_as, list): for alt_actor in also_known_as: if alt_actor == actor: continue if ctr > 0: other_accounts_html += ' ' ctr += 1 alt_domain, _ = get_domain_from_actor(alt_actor) if alt_domain: other_accounts_html += \ '' + alt_domain + '' elif isinstance(also_known_as, str): if also_known_as != actor: ctr += 1 alt_domain, _ = get_domain_from_actor(also_known_as) if alt_domain: other_accounts_html += \ '' + \ alt_domain + '' other_accounts_html += '

\n' if ctr > 0: html_str += other_accounts_html if is_valid_date(birth_date): birth_date = remove_html(birth_date) html_str += \ '

' + translate['Birthday'] + ': ' + \ birth_date + '

\n' if featured_hashtags: featured_hashtags += '\n' if website_url: html_str += '

🌐 ' + \ website_url + '

\n' if repo_url: html_str += '

💻 ' + \ repo_url + '

\n' html_str += \ '

' + profile_description_short + '

\n' + \ featured_hashtags # show any notes about this account if authorized: handle = search_nickname + '@' + search_domain_full person_notes = \ get_person_notes(base_dir, nickname, domain, handle) if person_notes: person_notes_html = person_notes.replace('\n', '
') html_str += '

' + \ translate['Notes'].upper() + ': ' + \ person_notes_html + '

\n' html_str += \ '
\n' + \ '
\n\n' if attached_shared_items: html_str += \ _profile_shared_items_list(attached_shared_items, translate) # book events for this actor html_str += html_profile_book_list(base_dir, person_url, no_of_books, translate, nickname, domain, authorized) return html_str def html_profile(signing_priv_key_pem: str, rss_icon_at_top: bool, icons_as_buttons: bool, default_timeline: str, recent_posts_cache: {}, max_recent_posts: int, translate: {}, project_version: str, base_dir: str, http_prefix: str, authorized: bool, profile_json: {}, selected: str, session, cached_webfingers: {}, person_cache: {}, yt_replace_domain: str, twitter_replacement_domain: str, show_published_date_only: bool, newswire: {}, theme: str, dormant_months: int, peertube_instances: [], allow_local_network_access: bool, text_mode_banner: str, debug: bool, access_keys: {}, city: str, system_language: str, max_like_count: int, shared_items_federated_domains: [], extra_json: {}, page_number: int, max_items_per_page: int, cw_lists: {}, lists_enabled: str, content_license_url: str, timezone: str, bold_reading: bool, buy_sites: {}, actor_proxied: str, max_shares_on_profile: int, sites_unavailable: [], no_of_books: int, auto_cw_cache: {}) -> str: """Show the profile page as html """ show_moved_accounts = False if authorized: moved_accounts_filename = data_dir(base_dir) + '/actors_moved.txt' if os.path.isfile(moved_accounts_filename): show_moved_accounts = True nickname = profile_json['preferredUsername'] if not nickname: return "" if is_system_account(nickname): min_images_for_accounts = [] return html_front_screen(signing_priv_key_pem, rss_icon_at_top, icons_as_buttons, default_timeline, recent_posts_cache, max_recent_posts, translate, project_version, base_dir, http_prefix, authorized, profile_json, session, cached_webfingers, person_cache, yt_replace_domain, twitter_replacement_domain, show_published_date_only, newswire, theme, extra_json, allow_local_network_access, access_keys, system_language, max_like_count, shared_items_federated_domains, cw_lists, lists_enabled, {}, min_images_for_accounts, buy_sites, auto_cw_cache) domain, port = get_domain_from_actor(profile_json['id']) if not domain: return "" display_name = remove_html(profile_json['name']) display_name = standardize_text(display_name) display_name = \ add_emoji_to_display_name(session, base_dir, http_prefix, nickname, domain, display_name, False, translate) domain_full = get_full_domain(domain, port) if not dangerous_markup(profile_json['summary'], False, []): profile_description = profile_json['summary'] else: profile_description = remove_html(profile_json['summary']) profile_description = \ remove_link_trackers_from_content(profile_description) profile_description = \ add_emoji_to_display_name(session, base_dir, http_prefix, nickname, domain, profile_description, False, translate) if profile_description: profile_description = standardize_text(profile_description) featured_hashtags = \ get_featured_hashtags_as_html(profile_json, profile_description) posts_button = 'button' following_button = 'button' moved_button = 'button' moved_button = 'button' inactive_button = 'button' followers_button = 'button' roles_button = 'button' skills_button = 'button' # shares_button = 'button' # wanted_button = 'button' if selected == 'posts': posts_button = 'buttonselected' elif selected == 'following': following_button = 'buttonselected' elif selected == 'moved': moved_button = 'buttonselected' elif selected == 'inactive': inactive_button = 'buttonselected' elif selected == 'followers': followers_button = 'buttonselected' elif selected == 'roles': roles_button = 'buttonselected' elif selected == 'skills': skills_button = 'buttonselected' # elif selected == 'shares': # shares_button = 'buttonselected' # elif selected == 'wanted': # wanted_button = 'buttonselected' login_button = '' follow_approvals_section = '' follow_approvals = False edit_profile_str = '' logout_str = '' actor = profile_json['id'] users_path = '/users/' + actor.split('/users/')[1] donate_section = '' donate_url = get_donation_url(profile_json) website_url = get_website(profile_json, translate) repo_url = get_repo_url(profile_json) gemini_link = get_gemini_link(profile_json) blog_address = get_blog_address(profile_json) enigma_pub_key = get_enigma_pub_key(profile_json) pgp_pub_key = get_pgp_pub_key(profile_json) pgp_fingerprint = get_pgp_fingerprint(profile_json) email_address = get_email_address(profile_json) xmpp_address = get_xmpp_address(profile_json) matrix_address = get_matrix_address(profile_json) ssb_address = get_ssb_address(profile_json) tox_address = get_tox_address(profile_json) briar_address = get_briar_address(profile_json) cwtch_address = get_cwtch_address(profile_json) verified_site_checkmark = '✔' premium = is_premium_account(base_dir, nickname, domain) if donate_url or website_url or repo_url or xmpp_address or \ matrix_address or ssb_address or tox_address or briar_address or \ cwtch_address or pgp_pub_key or enigma_pub_key or \ pgp_fingerprint or email_address: donate_section = '
\n' donate_section += '
\n' if donate_url and not is_system_account(nickname): donate_str = translate['Donate'] if premium: donate_str = translate['Subscribe'] donate_section += \ '

' + \ '

\n' if website_url: if site_is_verified(session, base_dir, http_prefix, nickname, domain, website_url, False, debug): donate_section += \ '

' + \ translate['Website'] + ': ' + \ verified_site_checkmark + '' + \ website_url + '

\n' else: donate_section += \ '

' + translate['Website'] + ': ' + \ '' + \ website_url + '

\n' if repo_url: donate_section += \ '

💻 ' + \ repo_url + '

\n' if gemini_link: donate_section += \ '

' + 'Gemini' + ': ' + \ gemini_link + '

\n' if email_address: donate_section += \ '

' + translate['Email'] + ': ' + \ email_address + '

\n' if blog_address: if site_is_verified(session, base_dir, http_prefix, nickname, domain, blog_address, False, debug): donate_section += \ '

' + \ 'Blog: ' + verified_site_checkmark + \ '' + \ blog_address + '

\n' else: donate_section += \ '

Blog: ' + \ blog_address + '

\n' if xmpp_address: donate_section += \ '

' + translate['XMPP'] + ': ' + xmpp_address + '

\n' if matrix_address: donate_section += \ '

' + translate['Matrix'] + ': ' + matrix_address + '

\n' if ssb_address: donate_section += \ '

SSB:

\n' if tox_address: donate_section += \ '

Tox:

\n' if briar_address: if briar_address.startswith('briar://'): donate_section += \ '

\n' else: donate_section += \ '

briar://

\n' if cwtch_address: donate_section += \ '

Cwtch:

\n' if enigma_pub_key: donate_section += \ '

Enigma:

\n' if pgp_fingerprint: donate_section += \ '

' + translate['PGP Fingerprint'] + ': ' + \ pgp_fingerprint.replace('\n', '
') + '

\n' if pgp_pub_key: donate_section += \ '
' + \ translate['PGP Public Key'] + \ '
' + \ pgp_pub_key.replace('\n', '
') + \ '
\n' donate_section += '
\n' donate_section += '
\n' if authorized: edit_profile_str = \ '' + \ '| ' + translate['Edit'] + '\n' logout_str = \ '' + \ '| ' + translate['Logout'] + \
            '\n' # are there any follow requests? follow_requests_filename = \ acct_dir(base_dir, nickname, domain) + '/followrequests.txt' if os.path.isfile(follow_requests_filename): try: with open(follow_requests_filename, 'r', encoding='utf-8') as fp_foll: for line in fp_foll: if len(line) > 0: follow_approvals = True followers_button = 'buttonhighlighted' if selected == 'followers': followers_button = 'buttonselectedhighlighted' break except OSError as exc: print('EX: html_profile unable to read ' + follow_requests_filename + ' ' + str(exc)) if selected == 'followers': if follow_approvals: curr_follower_domains = \ get_follower_domains(base_dir, nickname, domain) with open(follow_requests_filename, 'r', encoding='utf-8') as fp_req: for follower_handle in fp_req: if len(follower_handle) > 0: follower_handle = \ remove_eol(follower_handle) if '://' in follower_handle: follower_actor = follower_handle else: nick = follower_handle.split('@')[0] dom = follower_handle.split('@')[1] follower_actor = \ local_actor_url(http_prefix, nick, dom) # is this a new domain? # if so then append a new instance indicator follower_domain, _ = \ get_domain_from_actor(follower_actor) new_follower_domain = '' if follower_domain not in curr_follower_domains: new_follower_domain = ' ✨' # Show the handle of the potential follower # being approved, linking to search on that handle base_path = '/users/' + nickname follow_approvals_section += \ '
\n' + \ '
\n' + \ ' \n' + \ ' \n \n
\n' # show Approve and Deny buttons follow_approvals_section += \ '' follow_approvals_section += \ '

' follow_approvals_section += \ '' follow_approvals_section += \ '' follow_approvals_section += '
' profile_description_short = \ _get_profile_short_description(profile_description) # remove formatting from profile description used on title avatar_description = '' if profile_json.get('summary'): avatar_description = profile_json['summary'].replace('
', '\n') avatar_description = avatar_description.replace('

', '') avatar_description = avatar_description.replace('

', '') moved_to = '' if profile_json.get('movedTo'): moved_to = profile_json['movedTo'] if '"' in moved_to: moved_to = moved_to.split('"')[1] also_known_as = None if profile_json.get('alsoKnownAs'): also_known_as = profile_json['alsoKnownAs'] joined_date = None if profile_json.get('published'): if 'T' in profile_json['published']: joined_date = profile_json['published'] occupation_name = None if profile_json.get('hasOccupation'): occupation_name = get_occupation_name(profile_json) url_str = get_url_from_post(profile_json['icon']['url']) avatar_url = remove_html(url_str) # use alternate path for local avatars to avoid any caching issues if '://' + domain_full + '/system/accounts/avatars/' in avatar_url: avatar_url = \ avatar_url.replace('://' + domain_full + '/system/accounts/avatars/', '://' + domain_full + '/users/') account_dir = acct_dir(base_dir, nickname, domain) # get pinned post content pinned_filename = account_dir + '/pinToProfile.txt' pinned_content = None if os.path.isfile(pinned_filename): try: with open(pinned_filename, 'r', encoding='utf-8') as fp_pin: pinned_content = fp_pin.read() except OSError: print('EX: html_profile unable to read ' + pinned_filename) # shared items attached to the actor # https://codeberg.org/fediverse/fep/src/branch/main/fep/0837/fep-0837.md attached_shared_items = \ actor_attached_shares_as_html(profile_json, max_shares_on_profile) birth_date = '' if profile_json.get('vcard:bday'): birth_date = profile_json['vcard:bday'] profile_header_str = \ _get_profile_header(base_dir, http_prefix, nickname, domain, domain_full, translate, default_timeline, display_name, profile_description_short, featured_hashtags, login_button, avatar_url, theme, moved_to, also_known_as, pinned_content, attached_shared_items, access_keys, joined_date, occupation_name, actor_proxied, actor, no_of_books, authorized, birth_date, premium) # keyboard navigation user_path_str = '/users/' + nickname deft = default_timeline is_group = False followers_str = translate['Followers'] if premium: followers_str = translate['Fans'] if is_group_account(base_dir, nickname, domain): is_group = True followers_str = translate['Members'] menu_timeline = \ html_hide_from_screen_reader('🏠') + ' ' + \ translate['Switch to timeline view'] menu_edit = \ html_hide_from_screen_reader('✍') + ' ' + translate['Edit'] menu_followers = \ html_hide_from_screen_reader('👪') + ' ' + followers_str if show_moved_accounts: menu_moved = \ html_hide_from_screen_reader('⌂') + ' ' + translate['Moved'] menu_inactive = \ html_hide_from_screen_reader('💤') + ' ' + translate['Inactive'] menu_logout = \ html_hide_from_screen_reader('❎') + ' ' + translate['Logout'] if not show_moved_accounts: nav_links = { menu_timeline: user_path_str + '/' + deft, menu_edit: user_path_str + '/editprofile', menu_followers: user_path_str + '/followers#timeline', menu_logout: '/logout' } else: nav_links = { menu_timeline: user_path_str + '/' + deft, menu_edit: user_path_str + '/editprofile', menu_followers: user_path_str + '/followers#timeline', menu_moved: user_path_str + '/moved#timeline', menu_inactive: user_path_str + '/inactive#timeline', menu_logout: '/logout' } if not is_group: menu_following = \ html_hide_from_screen_reader('👥') + ' ' + translate['Following'] nav_links[menu_following] = user_path_str + '/following#timeline' menu_roles = \ html_hide_from_screen_reader('🤚') + ' ' + translate['Roles'] nav_links[menu_roles] = user_path_str + '/roles#timeline' menu_skills = \ html_hide_from_screen_reader('🛠') + ' ' + translate['Skills'] nav_links[menu_skills] = user_path_str + '/skills#timeline' if is_artist(base_dir, nickname): menu_theme_designer = \ html_hide_from_screen_reader('🎨') + ' ' + \ translate['Theme Designer'] nav_links[menu_theme_designer] = user_path_str + '/themedesigner' nav_access_keys = {} for variable_name, key in access_keys.items(): if not locals().get(variable_name): continue nav_access_keys[locals()[variable_name]] = key profile_str = html_keyboard_navigation(text_mode_banner, nav_links, nav_access_keys, None, None, None, False) profile_str += profile_header_str + donate_section profile_str += '
\n' profile_str += '
' profile_str += \ ' ' + \ '' if not is_group: profile_str += \ ' ' + \ '' profile_str += \ ' ' + \ '' profile_str += \ ' ' + \ '' if not is_group: if show_moved_accounts: profile_str += \ ' ' + \ '' profile_str += \ ' ' + \ '' profile_str += \ ' ' + \ '' # profile_str += \ # ' ' + \ # '' # profile_str += \ # ' ' + \ # '' profile_str += logout_str + edit_profile_str profile_str += '
' profile_str += '
' # search for following or followers if authorized: if selected in ('following', 'followers'): follow_search_str = '
\n' follow_search_str += \ '
\n' follow_search_str += \ ' \n' follow_search_str += \ html_following_dropdown(base_dir, nickname, domain, domain_full, selected, False) follow_search_str += \ ' \n' follow_search_str += '
\n
\n' profile_str += follow_search_str # start of #timeline profile_str += '
\n' profile_str += follow_approvals_section css_filename = base_dir + '/epicyon-profile.css' if os.path.isfile(base_dir + '/epicyon.css'): css_filename = base_dir + '/epicyon.css' license_str = \ '' + \ '' + \
        translate['Get the source code'] + '' if selected == 'posts' and not premium: max_profile_posts = \ get_max_profile_posts(base_dir, nickname, domain, 20) min_images_for_accounts = [] profile_str += \ _html_profile_posts(recent_posts_cache, max_profile_posts, translate, base_dir, http_prefix, authorized, nickname, domain, port, session, cached_webfingers, person_cache, project_version, yt_replace_domain, twitter_replacement_domain, show_published_date_only, peertube_instances, allow_local_network_access, theme, system_language, max_like_count, signing_priv_key_pem, cw_lists, lists_enabled, timezone, bold_reading, {}, min_images_for_accounts, max_profile_posts, buy_sites, auto_cw_cache) + license_str if not is_group: if selected == 'following': profile_str += \ _html_profile_following(translate, base_dir, http_prefix, authorized, nickname, domain, session, cached_webfingers, person_cache, extra_json, project_version, ["unfollow"], selected, users_path, page_number, max_items_per_page, dormant_months, debug, signing_priv_key_pem, sites_unavailable, system_language) if show_moved_accounts and selected == 'moved': profile_str += \ _html_profile_following(translate, base_dir, http_prefix, authorized, nickname, domain, session, cached_webfingers, person_cache, extra_json, project_version, ["moveAccount"], selected, users_path, page_number, max_items_per_page, dormant_months, debug, signing_priv_key_pem, sites_unavailable, system_language) if selected == 'followers': profile_str += \ _html_profile_following(translate, base_dir, http_prefix, authorized, nickname, domain, session, cached_webfingers, person_cache, extra_json, project_version, ["block"], selected, users_path, page_number, max_items_per_page, dormant_months, debug, signing_priv_key_pem, sites_unavailable, system_language) if authorized and selected == 'inactive': profile_str += \ _html_profile_following(translate, base_dir, http_prefix, authorized, nickname, domain, session, cached_webfingers, person_cache, extra_json, project_version, ["block"], selected, users_path, page_number, max_items_per_page, dormant_months, debug, signing_priv_key_pem, sites_unavailable, system_language) if not is_group: if selected == 'roles': profile_str += \ _html_profile_roles(translate, nickname, domain_full, extra_json) elif selected == 'skills': profile_str += \ _html_profile_skills(extra_json) # elif selected == 'shares': # profile_str += \ # _html_profile_shares(actor, translate, # domain_full, # extra_json, 'shares') + license_str # elif selected == 'wanted': # profile_str += \ # _html_profile_shares(actor, translate, # domain_full, # extra_json, 'wanted') + license_str # end of #timeline profile_str += '
\n
\n' instance_title = \ get_config_param(base_dir, 'instanceTitle') profile_str = \ html_header_with_person_markup(css_filename, instance_title, profile_json, city, content_license_url) + \ profile_str + html_footer() return profile_str def _html_profile_posts(recent_posts_cache: {}, max_recent_posts: int, translate: {}, base_dir: str, http_prefix: str, authorized: bool, nickname: str, domain: str, port: int, session, cached_webfingers: {}, person_cache: {}, project_version: str, 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: {}, min_images_for_accounts: [], max_profile_posts: int, buy_sites: {}, auto_cw_cache: {}) -> str: """Shows posts on the profile screen These should only be public posts """ separator_str = html_post_separator(base_dir, None) profile_str = '' max_items = max_profile_posts ctr = 0 curr_page = 1 box_name = 'outbox' minimize_all_images = False if nickname in min_images_for_accounts: minimize_all_images = True while ctr < max_items and curr_page < 4: outbox_feed_path_str = \ '/users/' + nickname + '/' + box_name + '?page=' + \ str(curr_page) outbox_feed = \ person_box_json({}, base_dir, domain, port, outbox_feed_path_str, http_prefix, 10, box_name, authorized, 0, False, 0) if not outbox_feed: break if len(outbox_feed['orderedItems']) == 0: break shown_items = [] for item in outbox_feed['orderedItems']: if item['type'] == 'Create': if not item['object'].get('id'): continue item_id = remove_id_ending(item['object']['id']) 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, item, None, True, False, http_prefix, project_version, 'inbox', yt_replace_domain, twitter_replacement_domain, show_published_date_only, peertube_instances, allow_local_network_access, theme_name, system_language, max_like_count, False, False, False, True, False, False, cw_lists, lists_enabled, timezone, False, bold_reading, dogwhistles, minimize_all_images, None, buy_sites, auto_cw_cache) if post_str and item_id not in shown_items: profile_str += post_str + separator_str shown_items.append(item_id) ctr += 1 if ctr >= max_items: break curr_page += 1 return profile_str def _html_profile_following(translate: {}, base_dir: str, http_prefix: str, authorized: bool, nickname: str, domain: str, session, cached_webfingers: {}, person_cache: {}, following_json: {}, project_version: str, buttons: [], feed_name: str, actor: str, page_number: int, max_items_per_page: int, dormant_months: int, debug: bool, signing_priv_key_pem: str, sites_unavailable: [], system_language: str) -> str: """Shows following on the profile screen """ profile_str = '' if authorized and page_number: if authorized and page_number > 1: # page up arrow profile_str += \ '
\n' + \ ' ' + \
                translate['Page up'] + '\n' + \ '
\n' if not following_json: following_json = { 'orderedItems': [] } for following_actor in following_json['orderedItems']: # is this a dormant followed account? dormant = False offline = False following_domain, _ = get_domain_from_actor(following_actor) if authorized: if following_domain in sites_unavailable: dormant = True offline = True else: if feed_name == 'following': dormant = \ is_dormant(base_dir, nickname, domain, following_actor, dormant_months) profile_str += \ _individual_follow_as_html(signing_priv_key_pem, translate, base_dir, session, cached_webfingers, person_cache, domain, following_actor, authorized, nickname, http_prefix, project_version, dormant, offline, debug, system_language, buttons) if authorized and max_items_per_page and page_number: if len(following_json['orderedItems']) >= max_items_per_page: # page down arrow profile_str += \ '
\n' + \ ' ' + \
                translate['Page down'] + '\n' + \ '
\n' # list of page numbers profile_str += \ page_number_buttons(actor, feed_name, page_number, 'buttonheader') # some vertical padding to allow "finger space" on mobile profile_str += '
' return profile_str def _html_profile_roles(translate: {}, nickname: str, domain: str, roles_list: []) -> str: """Shows roles on the profile screen """ profile_str = '' profile_str += \ '
\n
\n' for role in roles_list: if translate.get(role): profile_str += '

' + translate[role] + '

\n' else: profile_str += '

' + role + '

\n' profile_str += '
\n' if len(profile_str) == 0: profile_str += \ '

@' + nickname + '@' + domain + ' has no roles assigned

\n' else: profile_str = '
' + profile_str + '
\n' return profile_str def _html_profile_skills(skills_json: {}) -> str: """Shows skills on the profile screen """ profile_str = '' for skill, level in skills_json.items(): profile_str += \ '
' + skill + \ '
\n
\n' if len(profile_str) > 0: profile_str = '
' + \ profile_str + '
\n' return profile_str def _html_profile_shares(actor: str, translate: {}, domain: str, shares_json: {}, shares_file_type: str) -> str: """Shows shares on the profile screen """ profile_str = '' for item in shares_json['orderedItems']: profile_str += html_individual_share(domain, item['shareId'], actor, item, translate, False, False, shares_file_type) if len(profile_str) > 0: profile_str = '
' + profile_str + '
\n' return profile_str def _grayscale_enabled(base_dir: str) -> bool: """Is grayscale UI enabled? """ dir_str = data_dir(base_dir) return os.path.isfile(dir_str + '/.grayscale') def _html_themes_dropdown(base_dir: str, translate: {}) -> str: """Returns the html for theme selection dropdown """ # Themes section themes = get_themes_list(base_dir) themes_dropdown = '
\n' grayscale = _grayscale_enabled(base_dir) themes_dropdown += \ edit_check_box(translate['Grayscale'], 'grayscale', grayscale) dyslexic_font = get_config_param(base_dir, 'dyslexicFont') themes_dropdown += \ edit_check_box(translate['Dyslexic font'], 'dyslexicFont', dyslexic_font) themes_dropdown += '
' if os.path.isfile(base_dir + '/fonts/custom.woff') or \ os.path.isfile(base_dir + '/fonts/custom.woff2') or \ os.path.isfile(base_dir + '/fonts/custom.otf') or \ os.path.isfile(base_dir + '/fonts/custom.ttf'): themes_dropdown += \ edit_check_box(translate['Remove the custom font'], 'removeCustomFont', False) theme_name = get_config_param(base_dir, 'theme') themes_dropdown = \ themes_dropdown.replace('