epicyon/cache.py

360 lines
13 KiB
Python

__filename__ = "cache.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.5.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@libreserver.org"
__status__ = "Production"
__module_group__ = "Core"
import os
from session import download_image
from session import url_exists
from session import get_json
from session import get_json_valid
from flags import url_permitted
from utils import remove_html
from utils import get_url_from_post
from utils import data_dir
from utils import get_attributed_to
from utils import remove_id_ending
from utils import get_post_attachments
from utils import has_object_dict
from utils import contains_statuses
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
from content import remove_script
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 contains_statuses(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,
mitm_servers: []) -> 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,
mitm_servers, 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
def cache_svg_images(session, base_dir: str, http_prefix: str,
domain: str, domain_full: str,
onion_domain: str, i2p_domain: str,
post_json_object: {},
federation_list: [], debug: bool,
test_image_filename: str) -> bool:
"""Creates a local copy of a remote svg file
"""
if has_object_dict(post_json_object):
obj = post_json_object['object']
else:
obj = post_json_object
if not obj.get('id'):
return False
post_attachments = get_post_attachments(obj)
if not post_attachments:
return False
cached = False
post_id = remove_id_ending(obj['id']).replace('/', '--')
actor = 'unknown'
if post_attachments and obj.get('attributedTo'):
actor = get_attributed_to(obj['attributedTo'])
log_filename = data_dir(base_dir) + '/svg_scripts_log.txt'
for index in range(len(post_attachments)):
attach = post_attachments[index]
if not attach.get('mediaType'):
continue
if not attach.get('url'):
continue
url_str = get_url_from_post(attach['url'])
if url_str.endswith('.svg') or \
'svg' in attach['mediaType']:
url = remove_html(url_str)
if not url_permitted(url, federation_list):
continue
# if this is a local image then it has already been
# validated on upload
if '://' + domain in url:
continue
if onion_domain:
if '://' + onion_domain in url:
continue
if i2p_domain:
if '://' + i2p_domain in url:
continue
if '/' in url:
filename = url.split('/')[-1]
else:
filename = url
if not test_image_filename:
image_filename = \
base_dir + '/media/' + post_id + '_' + filename
if not download_image(session, url,
image_filename, debug):
continue
else:
image_filename = test_image_filename
image_data = None
try:
with open(image_filename, 'rb') as fp_svg:
image_data = fp_svg.read()
except OSError:
print('EX: unable to read svg file data')
if not image_data:
continue
image_data = image_data.decode()
cleaned_up = \
remove_script(image_data, log_filename, actor, url)
if cleaned_up != image_data:
# write the cleaned up svg image
svg_written = False
cleaned_up = cleaned_up.encode('utf-8')
try:
with open(image_filename, 'wb') as fp_im:
fp_im.write(cleaned_up)
svg_written = True
except OSError:
print('EX: unable to write cleaned up svg ' + url)
if svg_written:
# convert to list if needed
if isinstance(obj['attachment'], dict):
obj['attachment'] = [obj['attachment']]
# change the url to be the local version
obj['attachment'][index]['url'] = \
http_prefix + '://' + domain_full + '/media/' + \
post_id + '_' + filename
cached = True
else:
cached = True
return cached