__filename__ = "cache.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.4.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" import os from session import url_exists from session import get_json from session import get_json_valid from utils import load_json from utils import save_json from utils import get_file_case_insensitive from utils import get_user_paths from utils import date_utcnow from utils import date_from_string_format def remove_person_from_cache(base_dir: str, person_url: str, person_cache: {}) -> bool: """Removes an actor from the cache """ cache_filename = base_dir + '/cache/actors/' + \ person_url.replace('/', '#') + '.json' if os.path.isfile(cache_filename): try: os.remove(cache_filename) except OSError: print('EX: unable to delete cached actor ' + str(cache_filename)) if person_cache.get(person_url): del person_cache[person_url] def clear_actor_cache(base_dir: str, person_cache: {}, clear_domain: str) -> None: """Clears the actor cache for the given domain This is useful if you know that a given instance has rotated their signing keys after a security incident """ if not clear_domain: return if '.' not in clear_domain: return actor_cache_dir = base_dir + '/cache/actors' for subdir, _, files in os.walk(actor_cache_dir): for fname in files: filename = os.path.join(subdir, fname) if not filename.endswith('.json'): continue if clear_domain not in fname: continue person_url = fname.replace('#', '/').replace('.json', '') remove_person_from_cache(base_dir, person_url, person_cache) break def check_for_changed_actor(session, base_dir: str, http_prefix: str, domain_full: str, person_url: str, avatar_url: str, person_cache: {}, timeout_sec: int): """Checks if the avatar url exists and if not then the actor has probably changed without receiving an actor/Person Update. So clear the actor from the cache and it will be refreshed when the next post from them is sent """ if not session or not avatar_url: return if domain_full in avatar_url: return if url_exists(session, avatar_url, timeout_sec, http_prefix, domain_full): return remove_person_from_cache(base_dir, person_url, person_cache) def store_person_in_cache(base_dir: str, person_url: str, person_json: {}, person_cache: {}, allow_write_to_file: bool) -> None: """Store an actor in the cache """ if 'statuses' in person_url or person_url.endswith('/actor'): # This is not an actor or person account return curr_time = date_utcnow() person_cache[person_url] = { "actor": person_json, "timestamp": curr_time.strftime("%Y-%m-%dT%H:%M:%SZ") } if not base_dir: return # store to file if not allow_write_to_file: return if os.path.isdir(base_dir + '/cache/actors'): cache_filename = base_dir + '/cache/actors/' + \ person_url.replace('/', '#') + '.json' if not os.path.isfile(cache_filename): save_json(person_json, cache_filename) def get_person_from_cache(base_dir: str, person_url: str, person_cache: {}) -> {}: """Get an actor from the cache """ # if the actor is not in memory then try to load it from file loaded_from_file = False if not person_cache.get(person_url): # does the person exist as a cached file? cache_filename = base_dir + '/cache/actors/' + \ person_url.replace('/', '#') + '.json' actor_filename = get_file_case_insensitive(cache_filename) if actor_filename: person_json = load_json(actor_filename) if person_json: store_person_in_cache(base_dir, person_url, person_json, person_cache, False) loaded_from_file = True if person_cache.get(person_url): if not loaded_from_file: # update the timestamp for the last time the actor was retrieved curr_time = date_utcnow() curr_time_str = curr_time.strftime("%Y-%m-%dT%H:%M:%SZ") person_cache[person_url]['timestamp'] = curr_time_str return person_cache[person_url]['actor'] return None def expire_person_cache(person_cache: {}): """Expires old entries from the cache in memory """ curr_time = date_utcnow() removals = [] for person_url, cache_json in person_cache.items(): cache_time = date_from_string_format(cache_json['timestamp'], ["%Y-%m-%dT%H:%M:%S%z"]) days_since_cached = (curr_time - cache_time).days if days_since_cached > 2: removals.append(person_url) if len(removals) > 0: for person_url in removals: del person_cache[person_url] print(str(len(removals)) + ' actors were expired from the cache') def store_webfinger_in_cache(handle: str, webfing, cached_webfingers: {}) -> None: """Store a webfinger endpoint in the cache """ cached_webfingers[handle] = webfing def get_webfinger_from_cache(handle: str, cached_webfingers: {}) -> {}: """Get webfinger endpoint from the cache """ if cached_webfingers.get(handle): return cached_webfingers[handle] return None def get_actor_public_key_from_id(person_json: {}, key_id: str) -> (str, str): """Returns the public key referenced by the given id https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md """ pub_key = None pub_key_id = None if person_json.get('publicKey'): if person_json['publicKey'].get('publicKeyPem'): pub_key = person_json['publicKey']['publicKeyPem'] if person_json['publicKey'].get('id'): pub_key_id = person_json['publicKey']['id'] elif person_json.get('assertionMethod'): if isinstance(person_json['assertionMethod'], list): for key_dict in person_json['assertionMethod']: if not key_dict.get('id') or \ not key_dict.get('publicKeyMultibase'): continue if key_id is None or key_dict['id'] == key_id: pub_key = key_dict['publicKeyMultibase'] pub_key_id = key_dict['id'] break if not pub_key and person_json.get('publicKeyPem'): pub_key = person_json['publicKeyPem'] if person_json.get('id'): pub_key_id = person_json['id'] return pub_key, pub_key_id def get_person_pub_key(base_dir: str, session, person_url: str, person_cache: {}, debug: bool, project_version: str, http_prefix: str, domain: str, onion_domain: str, i2p_domain: str, signing_priv_key_pem: str) -> str: """Get the public key for an actor """ original_person_url = person_url if not person_url: return None if '#/publicKey' in person_url: person_url = person_url.replace('#/publicKey', '') elif '/main-key' in person_url: person_url = person_url.replace('/main-key', '') else: person_url = person_url.replace('#main-key', '') users_paths = get_user_paths() for possible_users_path in users_paths: if person_url.endswith(possible_users_path + 'inbox'): if debug: print('DEBUG: Obtaining public key for shared inbox') person_url = \ person_url.replace(possible_users_path + 'inbox', '/inbox') break person_json = \ get_person_from_cache(base_dir, person_url, person_cache) if not person_json: if debug: print('DEBUG: Obtaining public key for ' + person_url) person_domain = domain if onion_domain: if '.onion/' in person_url: person_domain = onion_domain elif i2p_domain: if '.i2p/' in person_url: person_domain = i2p_domain profile_str = 'https://www.w3.org/ns/activitystreams' accept_str = \ 'application/activity+json; profile="' + profile_str + '"' as_header = { 'Accept': accept_str } person_json = \ get_json(signing_priv_key_pem, session, person_url, as_header, None, debug, project_version, http_prefix, person_domain) if not get_json_valid(person_json): if person_json is not None: if isinstance(person_json, dict): # return the error code return person_json return None pub_key, _ = get_actor_public_key_from_id(person_json, original_person_url) if not pub_key: if debug: print('DEBUG: Public key not found for ' + person_url) store_person_in_cache(base_dir, person_url, person_json, person_cache, True) return pub_key