diff --git a/README_commandline.md b/README_commandline.md index 9529671b9..27e57c1ad 100644 --- a/README_commandline.md +++ b/README_commandline.md @@ -372,3 +372,19 @@ To remove a shared item: ``` bash python3 epicyon.py --undoItemName "spanner" --nickname [yournick] --domain [yourdomain] --password [c2s password] ``` + +## Calendar + +The calendar for each account can be accessed via CalDav (RFC4791). This makes it easy to integrate the social calendar into other applications. For example, to obtain events for a month: + +```bash +python3 epicyon.py --dav --nickname [yournick] --domain [yourdomain] --year [year] --month [month number] +``` + +You will be prompted for your login password, or you can use the **--password** option. You can also use the **--day** option to obtain events for a particular day. + +The CalDav endpoint for an account is: + +```bash +yourdomain/calendars/yournick +``` diff --git a/auth.py b/auth.py index 13f89c40f..e137eeab6 100644 --- a/auth.py +++ b/auth.py @@ -94,16 +94,25 @@ def authorize_basic(base_dir: str, path: str, auth_header: str, 'contain a space character') return False if not has_users_path(path): - if debug: - print('DEBUG: basic auth - ' + - 'path for Authorization does not contain a user') - return False - path_users_section = path.split('/users/')[1] - if '/' not in path_users_section: - if debug: - print('DEBUG: basic auth - this is not a users endpoint') - return False - nickname_from_path = path_users_section.split('/')[0] + if not path.startswith('/calendars/'): + if debug: + print('DEBUG: basic auth - ' + + 'path for Authorization does not contain a user') + return False + if path.startswith('/calendars/'): + path_users_section = path.split('/calendars/')[1] + nickname_from_path = path_users_section + if '/' in nickname_from_path: + nickname_from_path = nickname_from_path.split('/')[0] + if '?' in nickname_from_path: + nickname_from_path = nickname_from_path.split('?')[0] + else: + path_users_section = path.split('/users/')[1] + if '/' not in path_users_section: + if debug: + print('DEBUG: basic auth - this is not a users endpoint') + return False + nickname_from_path = path_users_section.split('/')[0] if is_system_account(nickname_from_path): print('basic auth - attempted login using system account ' + nickname_from_path + ' in path') diff --git a/daemon.py b/daemon.py index ee114533b..bcf65847b 100644 --- a/daemon.py +++ b/daemon.py @@ -949,7 +949,7 @@ class PubServer(BaseHTTPRequestHandler): self.end_headers() def _http_return_code(self, http_code: int, http_description: str, - long_description: str) -> None: + long_description: str, etag: str) -> None: msg = \ '' + str(http_code) + '' \ '' \ @@ -965,6 +965,8 @@ class PubServer(BaseHTTPRequestHandler): self.send_header('Content-Type', 'text/html; charset=utf-8') msg_len_str = str(len(msg)) self.send_header('Content-Length', msg_len_str) + if etag: + self.send_header('ETag', etag) self.end_headers() if not self._write(msg): print('Error when showing ' + str(http_code)) @@ -973,71 +975,77 @@ class PubServer(BaseHTTPRequestHandler): if self.server.translate: ok_str = self.server.translate['This is nothing ' + 'less than an utter triumph'] - self._http_return_code(200, self.server.translate['Ok'], ok_str) + self._http_return_code(200, self.server.translate['Ok'], + ok_str, None) else: self._http_return_code(200, 'Ok', 'This is nothing less ' + - 'than an utter triumph') + 'than an utter triumph', None) - def _201(self) -> None: + def _201(self, etag: str) -> None: if self.server.translate: - ok_str = self.server.translate['Done'] + done_str = self.server.translate['It is done'] self._http_return_code(201, - self.server.translate['Created'], ok_str) + self.server.translate['Created'], done_str, + etag) else: - self._http_return_code(201, 'Created', - 'Done') + self._http_return_code(201, 'Created', 'It is done', etag) def _207(self) -> None: if self.server.translate: multi_str = self.server.translate['Lots of things'] self._http_return_code(207, self.server.translate['Multi Status'], - multi_str) + multi_str, None) else: self._http_return_code(207, 'Multi Status', - 'Lots of things') + 'Lots of things', None) def _403(self) -> None: if self.server.translate: self._http_return_code(403, self.server.translate['Forbidden'], - self.server.translate["You're not allowed"]) + self.server.translate["You're not allowed"], + None) else: self._http_return_code(403, 'Forbidden', - "You're not allowed") + "You're not allowed", None) def _404(self) -> None: if self.server.translate: self._http_return_code(404, self.server.translate['Not Found'], self.server.translate['These are not the ' + 'droids you are ' + - 'looking for']) + 'looking for'], + None) else: self._http_return_code(404, 'Not Found', 'These are not the ' + 'droids you are ' + - 'looking for') + 'looking for', None) def _304(self) -> None: if self.server.translate: self._http_return_code(304, self.server.translate['Not changed'], self.server.translate['The contents of ' + 'your local cache ' + - 'are up to date']) + 'are up to date'], + None) else: self._http_return_code(304, 'Not changed', 'The contents of ' + 'your local cache ' + - 'are up to date') + 'are up to date', + None) def _400(self) -> None: if self.server.translate: self._http_return_code(400, self.server.translate['Bad Request'], self.server.translate['Better luck ' + - 'next time']) + 'next time'], + None) else: self._http_return_code(400, 'Bad Request', - 'Better luck next time') + 'Better luck next time', None) def _503(self) -> None: if self.server.translate: @@ -1045,11 +1053,11 @@ class PubServer(BaseHTTPRequestHandler): self.server.translate['The server is busy. ' + 'Please try again later'] self._http_return_code(503, self.server.translate['Unavailable'], - busy_str) + busy_str, None) else: self._http_return_code(503, 'Unavailable', 'The server is busy. Please try again ' + - 'later') + 'later', None) def _write(self, msg) -> bool: tries = 0 @@ -16824,13 +16832,15 @@ class PubServer(BaseHTTPRequestHandler): '_GET', 'end benchmarks', self.server.debug) - def _dav_handler(self, endpoint_type: str): + def _dav_handler(self, endpoint_type: str, debug: bool): calling_domain = self.server.domain_full if not self._has_accept(calling_domain): self._400() return accept_str = self.headers['Accept'] if 'application/xml' not in accept_str: + if debug: + print(endpoint_type.upper() + ' is not of xml type') self._400() return if not self.headers.get('Content-length'): @@ -16847,8 +16857,10 @@ class PubServer(BaseHTTPRequestHandler): print(endpoint_type.upper() + ' without /calendars ' + self.path) self._404() return + if debug: + print(endpoint_type.upper() + ' checking authorization') if not self._is_authorized(): - print('PROPFIND Not authorized') + print(endpoint_type.upper() + ' not authorized') self._403() return nickname = self.path.split('/calendars/')[1] @@ -16869,9 +16881,10 @@ class PubServer(BaseHTTPRequestHandler): propfind_bytes = self.rfile.read(length) except SocketError as ex: if ex.errno == errno.ECONNRESET: - print('EX: PROPFIND connection reset by peer') + print('EX: ' + endpoint_type.upper() + + ' connection reset by peer') else: - print('EX: PROPFIND socket error') + print('EX: ' + endpoint_type.upper() + ' socket error') self._400() return except ValueError as ex: @@ -16898,7 +16911,8 @@ class PubServer(BaseHTTPRequestHandler): nickname, self.server.domain, depth, propfind_xml, self.server.http_prefix, - self.server.system_language) + self.server.system_language, + self.server.recent_dav_etags) elif endpoint_type == 'report': curr_etag = None if self.headers.get('ETag'): @@ -16912,6 +16926,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.person_cache, self.server.http_prefix, curr_etag, + self.server.recent_dav_etags, self.server.domain_full) elif endpoint_type == 'delete': response_str = \ @@ -16925,7 +16940,13 @@ class PubServer(BaseHTTPRequestHandler): self._404() return if response_str == 'Not modified': - return self._304() + if endpoint_type == 'put': + return self._200() + else: + return self._304() + elif response_str.startswith('ETag:') and endpoint_type == 'put': + response_etag = response_str.split('ETag:', 1)[1] + self._201(response_etag) elif response_str != 'Ok': message_xml = response_str.encode('utf-8') message_xml_len = len(message_xml) @@ -16935,22 +16956,19 @@ class PubServer(BaseHTTPRequestHandler): self._write(message_xml) if 'multistatus' in response_str: return self._207() - if endpoint_type == 'put': - self._201() - else: - self._200() + self._200() def do_PROPFIND(self): - self._dav_handler('propfind') + self._dav_handler('propfind', self.server.debug) def do_PUT(self): - self._dav_handler('put') + self._dav_handler('put', self.server.debug) def do_REPORT(self): - self._dav_handler('report') + self._dav_handler('report', self.server.debug) def do_DELETE(self): - self._dav_handler('delete') + self._dav_handler('delete', self.server.debug) def do_HEAD(self): calling_domain = self.server.domain_full @@ -19274,6 +19292,9 @@ def run_daemon(dyslexic_font: bool, default_reply_interval_hrs = 9999999 httpd.default_reply_interval_hrs = default_reply_interval_hrs + # recent caldav etags for each account + httpd.recent_dav_etags = {} + httpd.key_shortcuts = {} load_access_keys_for_accounts(base_dir, httpd.key_shortcuts, httpd.access_keys) diff --git a/desktop_client.py b/desktop_client.py index db0d89bf0..1b816b04d 100644 --- a/desktop_client.py +++ b/desktop_client.py @@ -466,6 +466,9 @@ def _desktop_reply_to_post(session, post_id: str, comments_enabled = True city = 'London, England' say_str = 'Sending reply' + event_date = None + event_time = None + location = None _say_command(say_str, say_str, screenreader, system_language, espeak) if send_post_via_server(signing_priv_key_pem, __version__, base_dir, session, nickname, password, @@ -477,6 +480,7 @@ def _desktop_reply_to_post(session, post_id: str, cached_webfingers, person_cache, is_article, system_language, languages_understood, low_bandwidth, content_license_url, + event_date, event_time, location, debug, post_id, post_id, conversation_id, subject) == 0: say_str = 'Reply sent' @@ -535,6 +539,9 @@ def _desktop_new_post(session, comments_enabled = True subject = None say_str = 'Sending' + event_date = None + event_time = None + location = None _say_command(say_str, say_str, screenreader, system_language, espeak) if send_post_via_server(signing_priv_key_pem, __version__, base_dir, session, nickname, password, @@ -546,6 +553,7 @@ def _desktop_new_post(session, cached_webfingers, person_cache, is_article, system_language, languages_understood, low_bandwidth, content_license_url, + event_date, event_time, location, debug, None, None, conversation_id, subject) == 0: say_str = 'Post sent' @@ -1260,6 +1268,10 @@ def _desktop_new_dm_base(session, to_handle: str, _say_command(say_str, say_str, screenreader, system_language, espeak) return + event_date = None + event_time = None + location = None + say_str = 'Sending' _say_command(say_str, say_str, screenreader, system_language, espeak) if send_post_via_server(signing_priv_key_pem, __version__, @@ -1272,6 +1284,7 @@ def _desktop_new_dm_base(session, to_handle: str, cached_webfingers, person_cache, is_article, system_language, languages_understood, low_bandwidth, content_license_url, + event_date, event_time, location, debug, None, None, conversation_id, subject) == 0: say_str = 'Direct message sent' diff --git a/epicyon.py b/epicyon.py index a98d161a7..043c51601 100644 --- a/epicyon.py +++ b/epicyon.py @@ -13,6 +13,7 @@ import sys import time import argparse import getpass +import datetime from person import get_actor_json from person import create_person from person import create_group @@ -101,6 +102,8 @@ from announce import send_announce_via_server from socnet import instances_graph from migrate import migrate_accounts from desktop_client import run_desktop_client +from happening import dav_month_via_server +from happening import dav_day_via_server def str2bool(value_str) -> bool: @@ -115,7 +118,19 @@ def str2bool(value_str) -> bool: raise argparse.ArgumentTypeError('Boolean value expected.') +search_date = datetime.datetime.now() parser = argparse.ArgumentParser(description='ActivityPub Server') +parser.add_argument('--eventDate', type=str, + default=None, + help='Date for an event when sending a c2s post' + + ' YYYY-MM-DD') +parser.add_argument('--eventTime', type=str, + default=None, + help='Time for an event when sending a c2s post' + + ' HH:MM') +parser.add_argument('--eventLocation', type=str, + default=None, + help='Location for an event when sending a c2s post') parser.add_argument('--content_license_url', type=str, default='https://creativecommons.org/licenses/by/4.0', help='Url of the license used for the instance content') @@ -172,6 +187,15 @@ parser.add_argument('--i2p_domain', dest='i2p_domain', type=str, parser.add_argument('-p', '--port', dest='port', type=int, default=None, help='Port number to run on') +parser.add_argument('--year', dest='year', type=int, + default=search_date.year, + help='Year for calendar query') +parser.add_argument('--month', dest='month', type=int, + default=search_date.month, + help='Month for calendar query') +parser.add_argument('--day', dest='day', type=int, + default=None, + help='Day for calendar query') parser.add_argument('--postsPerSource', dest='max_newswire_postsPerSource', type=int, default=4, @@ -329,6 +353,11 @@ parser.add_argument("--repliesEnabled", "--commentsEnabled", type=str2bool, nargs='?', const=True, default=True, help="Enable replies to a post") +parser.add_argument("--dav", + dest='dav', + type=str2bool, nargs='?', + const=True, default=False, + help="Caldav") parser.add_argument("--show_publish_as_icon", dest='show_publish_as_icon', type=str2bool, nargs='?', @@ -1296,6 +1325,16 @@ if args.message: print('Specify a nickname with the --nickname option') sys.exit() + if args.eventDate: + if '-' not in args.eventDate or len(args.eventDate) != 10: + print('Event date format should be YYYY-MM-DD') + sys.exit() + + if args.eventTime: + if ':' not in args.eventTime or len(args.eventTime) != 5: + print('Event time format should be HH:MM') + sys.exit() + if not args.password: args.password = getpass.getpass('Password: ') if not args.password: @@ -1356,8 +1395,8 @@ if args.message: if args.secure_mode: signing_priv_key_pem = get_instance_actor_key(base_dir, domain) languages_understood = [args.language] - print('Sending post to ' + args.sendto) + print('Sending post to ' + args.sendto) send_post_via_server(signing_priv_key_pem, __version__, base_dir, session, args.nickname, args.password, domain, port, @@ -1368,13 +1407,64 @@ if args.message: cached_webfingers, person_cache, is_article, args.language, languages_understood, args.low_bandwidth, - args.content_license_url, args.debug, + args.content_license_url, + args.eventDate, args.eventTime, args.eventLocation, + args.debug, reply_to, reply_to, args.conversationId, subject) for i in range(10): # TODO detect send success/fail time.sleep(1) sys.exit() +if args.dav: + if not args.nickname: + print('Please specify a nickname with --nickname') + sys.exit() + if not args.domain: + print('Please specify a domain with --domain') + sys.exit() + if not args.year: + print('Please specify a year with --year') + sys.exit() + if not args.month: + print('Please specify a month with --month') + sys.exit() + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + proxy_type = None + if args.tor or domain.endswith('.onion'): + proxy_type = 'tor' + if domain.endswith('.onion'): + args.port = 80 + elif args.i2p or domain.endswith('.i2p'): + proxy_type = 'i2p' + if domain.endswith('.i2p'): + args.port = 80 + elif args.gnunet: + proxy_type = 'gnunet' + session = create_session(proxy_type) + if args.day: + result = \ + dav_day_via_server(session, http_prefix, + args.nickname, args.domain, args.port, + args.debug, + args.year, args.month, args.day, + args.password) + else: + result = \ + dav_month_via_server(session, http_prefix, + args.nickname, args.domain, args.port, + args.debug, + args.year, args.month, + args.password) + if result: + print(str(result)) + sys.exit() + if args.announce: if not args.nickname: print('Specify a nickname with the --nickname option') diff --git a/happening.py b/happening.py index 72e38c9c1..947a993f7 100644 --- a/happening.py +++ b/happening.py @@ -23,8 +23,36 @@ from utils import remove_html from utils import get_display_name from utils import delete_post from utils import get_status_number +from utils import get_full_domain from filters import is_filtered from context import get_individual_post_context +from session import get_method +from auth import create_basic_auth_header + + +def _dav_date_from_string(timestamp: str) -> str: + """Returns a datetime from a caldav date + """ + timestamp_year = timestamp[:4] + timestamp_month = timestamp[4:][:2] + timestamp_day = timestamp[6:][:2] + timestamp_hour = timestamp[9:][:2] + timestamp_min = timestamp[11:][:2] + timestamp_sec = timestamp[13:][:2] + + if not timestamp_year.isdigit() or \ + not timestamp_month.isdigit() or \ + not timestamp_day.isdigit() or \ + not timestamp_hour.isdigit() or \ + not timestamp_min.isdigit() or \ + not timestamp_sec.isdigit(): + return None + if int(timestamp_year) < 2020 or int(timestamp_year) > 2100: + return None + published = \ + timestamp_year + '-' + timestamp_month + '-' + timestamp_day + 'T' + \ + timestamp_hour + ':' + timestamp_min + ':' + timestamp_sec + 'Z' + return published def _valid_uuid(test_uuid: str, version: int): @@ -181,9 +209,26 @@ def _is_happening_post(post_json_object: {}) -> bool: return True +def _event_text_match(content: str, text_match: str) -> bool: + """Returns true of the content matches the search text + """ + if not text_match: + return True + if '+' not in text_match: + if text_match.strip().lower() in content.lower(): + return True + else: + match_list = text_match.split('+') + for possible_match in match_list: + if possible_match.strip().lower() in content.lower(): + return True + return False + + def get_todays_events(base_dir: str, nickname: str, domain: str, curr_year: int, curr_month_number: int, - curr_day_of_month: int) -> {}: + curr_day_of_month: int, + text_match: str) -> {}: """Retrieves calendar events for today Returns a dictionary of lists containing Event and Place activities """ @@ -222,6 +267,12 @@ def get_todays_events(base_dir: str, nickname: str, domain: str, if not _is_happening_post(post_json_object): continue + if post_json_object.get('object'): + if post_json_object['object'].get('content'): + content = post_json_object['object']['content'] + if not _event_text_match(content, text_match): + continue + public_event = is_public_post(post_json_object) post_event = [] @@ -413,13 +464,15 @@ def _icalendar_day(base_dir: str, nickname: str, domain: str, def get_todays_events_icalendar(base_dir: str, nickname: str, domain: str, year: int, month_number: int, day_number: int, person_cache: {}, - http_prefix: str) -> str: + http_prefix: str, + text_match: str) -> str: """Returns today's events in icalendar format """ day_events = None events = \ get_todays_events(base_dir, nickname, domain, - year, month_number, day_number) + year, month_number, day_number, + text_match) if events: if events.get(str(day_number)): day_events = events[str(day_number)] @@ -447,12 +500,13 @@ def get_month_events_icalendar(base_dir: str, nickname: str, domain: str, year: int, month_number: int, person_cache: {}, - http_prefix: str) -> str: + http_prefix: str, + text_match: str) -> str: """Returns today's events in icalendar format """ month_events = \ get_calendar_events(base_dir, nickname, domain, year, - month_number) + month_number, text_match) ical_str = \ 'BEGIN:VCALENDAR\n' + \ @@ -597,7 +651,8 @@ def get_this_weeks_events(base_dir: str, nickname: str, domain: str) -> {}: def get_calendar_events(base_dir: str, nickname: str, domain: str, - year: int, month_number: int) -> {}: + year: int, month_number: int, + text_match: str) -> {}: """Retrieves calendar events Returns a dictionary indexed by day number of lists containing Event and Place activities @@ -621,9 +676,17 @@ def get_calendar_events(base_dir: str, nickname: str, domain: str, continue post_json_object = load_json(post_filename) + if not post_json_object: + continue if not _is_happening_post(post_json_object): continue + if post_json_object.get('object'): + if post_json_object['object'].get('content'): + content = post_json_object['object']['content'] + if not _event_text_match(content, text_match): + continue + post_event = [] day_of_month = None for tag in post_json_object['object']['tag']: @@ -784,66 +847,15 @@ def _dav_store_event(base_dir: str, nickname: str, domain: str, return False # convert to the expected time format - timestamp_year = timestamp[:4] - timestamp_month = timestamp[4:][:2] - timestamp_day = timestamp[6:][:2] - timestamp_hour = timestamp[9:][:2] - timestamp_min = timestamp[11:][:2] - timestamp_sec = timestamp[13:][:2] - - if not timestamp_year.isdigit() or \ - not timestamp_month.isdigit() or \ - not timestamp_day.isdigit() or \ - not timestamp_hour.isdigit() or \ - not timestamp_min.isdigit() or \ - not timestamp_sec.isdigit(): + published = _dav_date_from_string(timestamp) + if not published: return False - if int(timestamp_year) < 2020 or int(timestamp_year) > 2100: + start_time = _dav_date_from_string(start_time) + if not start_time: return False - published = \ - timestamp_year + '-' + timestamp_month + '-' + timestamp_day + 'T' + \ - timestamp_hour + ':' + timestamp_min + ':' + timestamp_sec + 'Z' - - start_time_year = start_time[:4] - start_time_month = start_time[4:][:2] - start_time_day = start_time[6:][:2] - start_time_hour = start_time[9:][:2] - start_time_min = start_time[11:][:2] - start_time_sec = start_time[13:][:2] - - if not start_time_year.isdigit() or \ - not start_time_month.isdigit() or \ - not start_time_day.isdigit() or \ - not start_time_hour.isdigit() or \ - not start_time_min.isdigit() or \ - not start_time_sec.isdigit(): + end_time = _dav_date_from_string(end_time) + if not end_time: return False - if int(start_time_year) < 2020 or int(start_time_year) > 2100: - return False - start_time = \ - start_time_year + '-' + start_time_month + '-' + \ - start_time_day + 'T' + \ - start_time_hour + ':' + start_time_min + ':' + start_time_sec + 'Z' - - end_time_year = end_time[:4] - end_time_month = end_time[4:][:2] - end_time_day = end_time[6:][:2] - end_time_hour = end_time[9:][:2] - end_time_min = end_time[11:][:2] - end_time_sec = end_time[13:][:2] - - if not end_time_year.isdigit() or \ - not end_time_month.isdigit() or \ - not end_time_day.isdigit() or \ - not end_time_hour.isdigit() or \ - not end_time_min.isdigit() or \ - not end_time_sec.isdigit(): - return False - if int(end_time_year) < 2020 or int(end_time_year) > 2100: - return False - end_time = \ - end_time_year + '-' + end_time_month + '-' + end_time_day + 'T' + \ - end_time_hour + ':' + end_time_min + ':' + end_time_sec + 'Z' post_id = '' post_context = get_individual_post_context() @@ -932,9 +944,26 @@ def _dav_store_event(base_dir: str, nickname: str, domain: str, return True +def _dav_update_recent_etags(etag: str, nickname: str, + recent_dav_etags: {}) -> None: + """Updates the recent etags for each account + """ + # update the recent caldav etags for each account + if not recent_dav_etags.get(nickname): + recent_dav_etags[nickname] = [etag] + else: + # only keep a limited number of recent etags + while len(recent_dav_etags[nickname]) > 32: + recent_dav_etags[nickname].pop(0) + # append the etag to the recent list + if etag not in recent_dav_etags[nickname]: + recent_dav_etags[nickname].append(etag) + + def dav_put_response(base_dir: str, nickname: str, domain: str, depth: int, xml_str: str, http_prefix: str, - system_language: str) -> str: + system_language: str, + recent_dav_etags: {}) -> str: """Returns the response to caldav PUT """ if '\n' not in xml_str: @@ -946,6 +975,11 @@ def dav_put_response(base_dir: str, nickname: str, domain: str, 'END:VEVENT' not in xml_str: return None + etag = md5(xml_str.encode('utf-8')).hexdigest() + if recent_dav_etags.get(nickname): + if etag in recent_dav_etags[nickname]: + return 'Not modified' + stored_count = 0 reading_event = False lines_list = xml_str.split('\n') @@ -968,13 +1002,14 @@ def dav_put_response(base_dir: str, nickname: str, domain: str, event_list.append(line) if stored_count == 0: return None - return 'Ok' + _dav_update_recent_etags(etag, nickname, recent_dav_etags) + return 'ETag:' + etag def dav_report_response(base_dir: str, nickname: str, domain: str, depth: int, xml_str: str, person_cache: {}, http_prefix: str, - curr_etag: str, + curr_etag: str, recent_dav_etags: {}, domain_full: str) -> str: """Returns the response to caldav REPORT """ @@ -983,42 +1018,227 @@ def dav_report_response(base_dir: str, nickname: str, domain: str, if '' not in xml_str: return None + + if curr_etag: + if recent_dav_etags.get(nickname): + if curr_etag in recent_dav_etags[nickname]: + return "Not modified" + + xml_str_lower = xml_str.lower() + query_start_time = None + query_end_time = None + if ':time-range' in xml_str_lower: + time_range_str = xml_str_lower.split(':time-range')[1] + if 'start=' in time_range_str and 'end=' in time_range_str: + start_time_str = time_range_str.split('start=')[1] + if start_time_str.startswith("'"): + query_start_time_str = start_time_str.split("'")[1] + query_start_time = _dav_date_from_string(query_start_time_str) + elif start_time_str.startswith('"'): + query_start_time_str = start_time_str.split('"')[1] + query_start_time = _dav_date_from_string(query_start_time_str) + + end_time_str = time_range_str.split('end=')[1] + if end_time_str.startswith("'"): + query_end_time_str = end_time_str.split("'")[1] + query_end_time = _dav_date_from_string(query_end_time_str) + elif end_time_str.startswith('"'): + query_end_time_str = end_time_str.split('"')[1] + query_end_time = _dav_date_from_string(query_end_time_str) + + text_match = '' + if ':text-match' in xml_str_lower: + match_str = xml_str_lower.split(':text-match')[1] + if '>' in match_str and '<' in match_str: + text_match = match_str.split('>')[1] + if '<' in text_match: + text_match = text_match.split('<')[0] + else: + text_match = '' + + ical_events = None + etag = None + events_href = '' + responses = '' + search_date = datetime.now() + if query_start_time and query_end_time: + query_start_year = int(query_start_time.split('-')[0]) + query_start_month = int(query_start_time.split('-')[1]) + query_start_day = query_start_time.split('-')[2] + query_start_day = int(query_start_day.split('T')[0]) + query_end_year = int(query_end_time.split('-')[0]) + query_end_month = int(query_end_time.split('-')[1]) + query_end_day = query_end_time.split('-')[2] + query_end_day = int(query_end_day.split('T')[0]) + if query_start_year == query_end_year and \ + query_start_month == query_end_month: + if query_start_day == query_end_day: + # calendar for one day + search_date = \ + datetime(year=query_start_year, + month=query_start_month, + day=query_start_day) + ical_events = \ + get_todays_events_icalendar(base_dir, nickname, domain, + search_date.year, + search_date.month, + search_date.day, person_cache, + http_prefix, text_match) + events_href = \ + http_prefix + '://' + domain_full + '/users/' + \ + nickname + '/calendar?year=' + \ + str(search_date.year) + '?month=' + \ + str(search_date.month) + '?day=' + str(search_date.day) + if ical_events: + if 'VEVENT' in ical_events: + ical_events_encoded = ical_events.encode('utf-8') + etag = md5(ical_events_encoded).hexdigest() + responses = \ + ' \n' + \ + ' ' + events_href + \ + '\n' + \ + ' \n' + \ + ' \n' + \ + ' "' + \ + etag + '"\n' + \ + ' ' + \ + ical_events + \ + ' \n' + \ + ' \n' + \ + ' HTTP/1.1 200 OK' + \ + '\n' + \ + ' \n' + \ + ' \n' + elif query_start_day == 1 and query_start_day >= 28: + # calendar for a month + ical_events = \ + get_month_events_icalendar(base_dir, nickname, domain, + query_start_year, + query_start_month, + person_cache, + http_prefix, + text_match) + events_href = \ + http_prefix + '://' + domain_full + '/users/' + \ + nickname + '/calendar?year=' + \ + str(query_start_year) + '?month=' + \ + str(query_start_month) + if ical_events: + if 'VEVENT' in ical_events: + ical_events_encoded = ical_events.encode('utf-8') + etag = md5(ical_events_encoded).hexdigest() + responses = \ + ' \n' + \ + ' ' + events_href + \ + '\n' + \ + ' \n' + \ + ' \n' + \ + ' "' + \ + etag + '"\n' + \ + ' ' + \ + ical_events + \ + ' \n' + \ + ' \n' + \ + ' HTTP/1.1 200 OK' + \ + '\n' + \ + ' \n' + \ + ' \n' + if not responses: + all_events = '' + for year in range(query_start_year, query_end_year+1): + if query_start_year == query_end_year: + start_month_number = query_start_month + end_month_number = query_end_month + elif year == query_end_year: + start_month_number = 1 + end_month_number = query_end_month + elif year == query_start_year: + start_month_number = query_start_month + end_month_number = 12 + else: + start_month_number = 1 + end_month_number = 12 + for month in range(start_month_number, end_month_number+1): + ical_events = \ + get_month_events_icalendar(base_dir, + nickname, domain, + year, month, + person_cache, + http_prefix, + text_match) + events_href = \ + http_prefix + '://' + domain_full + '/users/' + \ + nickname + '/calendar?year=' + \ + str(year) + '?month=' + \ + str(month) + if ical_events: + if 'VEVENT' in ical_events: + all_events += ical_events + ical_events_encoded = ical_events.encode('utf-8') + local_etag = md5(ical_events_encoded).hexdigest() + responses += \ + ' \n' + \ + ' ' + events_href + \ + '\n' + \ + ' \n' + \ + ' \n' + \ + ' "' + \ + local_etag + '"\n' + \ + ' ' + \ + ical_events + \ + ' \n' + \ + ' \n' + \ + ' HTTP/1.1 200 OK' + \ + '\n' + \ + ' \n' + \ + ' \n' + ical_events_encoded = all_events.encode('utf-8') + etag = md5(ical_events_encoded).hexdigest() + # today's calendar events - now = datetime.now() - ical_events = \ - get_todays_events_icalendar(base_dir, nickname, domain, - now.year, now.month, - now.day, person_cache, - http_prefix) if not ical_events: + ical_events = \ + get_todays_events_icalendar(base_dir, nickname, domain, + search_date.year, search_date.month, + search_date.day, person_cache, + http_prefix, text_match) + events_href = \ + http_prefix + '://' + domain_full + '/users/' + \ + nickname + '/calendar?year=' + \ + str(search_date.year) + '?month=' + \ + str(search_date.month) + '?day=' + str(search_date.day) + if ical_events: + if 'VEVENT' in ical_events: + ical_events_encoded = ical_events.encode('utf-8') + etag = md5(ical_events_encoded).hexdigest() + responses = \ + ' \n' + \ + ' ' + events_href + '\n' + \ + ' \n' + \ + ' \n' + \ + ' "' + etag + \ + '"\n' + \ + ' ' + ical_events + \ + ' \n' + \ + ' \n' + \ + ' HTTP/1.1 200 OK\n' + \ + ' \n' + \ + ' \n' + + if not ical_events or not etag: return None if 'VEVENT' not in ical_events: return None - etag = md5(ical_events).hexdigest() if etag == curr_etag: return "Not modified" - events_href = \ - http_prefix + '://' + domain_full + '/users/' + \ - nickname + '/calendar?year=' + \ - str(now.year) + '?month=' + str(now.month) + '?day=' + str(now.day) response_str = \ + '\n' + \ '\n' + \ - ' \n' + \ - ' ' + events_href + '\n' + \ - ' \n' + \ - ' \n' + \ - ' "' + etag + '"\n' + \ - ' ' + ical_events + \ - ' \n' + \ - ' \n' + \ - ' HTTP/1.1 200 OK\n' + \ - ' \n' + \ - ' \n' + \ - ' \n' + \ - '' + responses + '' + _dav_update_recent_etags(etag, nickname, recent_dav_etags) return response_str @@ -1048,3 +1268,92 @@ def dav_delete_response(base_dir: str, nickname: str, domain: str, nickname, domain, post_filename, debug, recent_posts_cache) return 'Ok' + + +def dav_month_via_server(session, http_prefix: str, + nickname: str, domain: str, port: int, + debug: bool, + year: int, month: int, + password: str) -> str: + """Gets the icalendar for a month via caldav + """ + auth_header = create_basic_auth_header(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/xml', + 'Authorization': auth_header + } + domain_full = get_full_domain(domain, port) + params = {} + url = http_prefix + '://' + domain_full + '/calendars/' + nickname + month_str = str(month) + if month < 10: + month_str = '0' + month_str + xml_str = \ + '\n' + \ + '\n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + '' + result = \ + get_method("REPORT", xml_str, session, url, params, headers, debug) + return result + + +def dav_day_via_server(session, http_prefix: str, + nickname: str, domain: str, port: int, + debug: bool, + year: int, month: int, day: int, + password: str) -> str: + """Gets the icalendar for a day via caldav + """ + auth_header = create_basic_auth_header(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/xml', + 'Authorization': auth_header + } + domain_full = get_full_domain(domain, port) + params = {} + url = http_prefix + '://' + domain_full + '/calendars/' + nickname + month_str = str(month) + if month < 10: + month_str = '0' + month_str + day_str = str(day) + if day < 10: + day_str = '0' + day_str + xml_str = \ + '\n' + \ + '\n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + '' + result = \ + get_method("REPORT", xml_str, session, url, params, headers, debug) + return result diff --git a/posts.py b/posts.py index 7137e4ede..256589f3b 100644 --- a/posts.py +++ b/posts.py @@ -2490,6 +2490,8 @@ def send_post_via_server(signing_priv_key_pem: str, project_version: str, languages_understood: [], low_bandwidth: bool, content_license_url: str, + event_date: str, event_time: str, + location: str, debug: bool = False, in_reply_to: str = None, in_reply_to_atom_uri: str = None, @@ -2574,8 +2576,9 @@ def send_post_via_server(signing_priv_key_pem: str, project_version: str, image_description, city, False, is_article, in_reply_to, in_reply_to_atom_uri, subject, - False, None, None, None, None, None, - None, None, None, + False, + event_date, event_time, location, + None, None, None, None, None, None, None, None, None, None, system_language, conversation_id, low_bandwidth, content_license_url, languages_understood) diff --git a/session.py b/session.py index 398c89328..0a2fc4247 100644 --- a/session.py +++ b/session.py @@ -102,10 +102,13 @@ def _get_json_request(session, url: str, domain_full: str, session_headers: {}, elif result.status_code == 410: print('WARN: get_json no longer available url: ' + url) else: + session_headers2 = session_headers.copy() + if session_headers2.get('Authorization'): + session_headers2['Authorization'] = 'REDACTED' print('WARN: get_json url: ' + url + ' failed with error code ' + str(result.status_code) + - ' headers: ' + str(session_headers)) + ' headers: ' + str(session_headers2)) if return_json: return result.json() return result.content @@ -210,7 +213,7 @@ def _get_json_signed(session, url: str, domain_full: str, session_headers: {}, def get_json(signing_priv_key_pem: str, session, url: str, headers: {}, params: {}, debug: bool, - version: str = '1.3.0', http_prefix: str = 'https', + version: str = __version__, http_prefix: str = 'https', domain: str = 'testdomain', timeout_sec: int = 20, quiet: bool = False) -> {}: if not isinstance(url, str): @@ -248,7 +251,7 @@ def get_json(signing_priv_key_pem: str, def get_vcard(xml_format: bool, session, url: str, params: {}, debug: bool, - version: str = '1.3.0', http_prefix: str = 'https', + version: str = __version__, http_prefix: str = 'https', domain: str = 'testdomain', timeout_sec: int = 20, quiet: bool = False) -> {}: if not isinstance(url, str): @@ -292,10 +295,13 @@ def get_vcard(xml_format: bool, elif result.status_code == 410: print('WARN: get_vcard no longer available url: ' + url) else: + session_headers2 = session_headers.copy() + if session_headers2.get('Authorization'): + session_headers2['Authorization'] = 'REDACTED' print('WARN: get_vcard url: ' + url + ' failed with error code ' + str(result.status_code) + - ' headers: ' + str(session_headers)) + ' headers: ' + str(session_headers2)) return result.content.decode('utf-8') except requests.exceptions.RequestException as ex: session_headers2 = session_headers.copy() @@ -323,7 +329,7 @@ def get_vcard(xml_format: bool, def download_html(signing_priv_key_pem: str, session, url: str, headers: {}, params: {}, debug: bool, - version: str = '1.3.0', http_prefix: str = 'https', + version: str = __version__, http_prefix: str = 'https', domain: str = 'testdomain', timeout_sec: int = 20, quiet: bool = False) -> {}: if not isinstance(url, str): @@ -664,3 +670,87 @@ def download_image_any_mime_type(session, url: str, if 'image/' + m_type in content_type: mime_type = 'image/' + m_type return result.content, mime_type + + +def get_method(method_name: str, xml_str: str, + session, url: str, params: {}, headers: {}, debug: bool, + version: str = __version__, http_prefix: str = 'https', + domain: str = 'testdomain', + timeout_sec: int = 20, quiet: bool = False) -> {}: + if method_name not in ("REPORT", "PUT", "PROPFIND"): + print("Unrecognized method: " + method_name) + return None + if not isinstance(url, str): + if debug and not quiet: + print('url: ' + str(url)) + print('ERROR: get_method failed, url should be a string') + return None + if not headers: + headers = { + 'Accept': 'application/xml' + } + else: + headers['Accept'] = 'application/xml' + session_params = {} + session_headers = {} + if headers: + session_headers = headers + if params: + session_params = params + session_headers['User-Agent'] = 'Epicyon/' + version + if domain: + session_headers['User-Agent'] += \ + '; +' + http_prefix + '://' + domain + '/' + if not session: + if not quiet: + print('WARN: get_method failed, ' + + 'no session specified for get_vcard') + return None + + if debug: + HTTPConnection.debuglevel = 1 + + try: + result = session.request(method_name, url, headers=session_headers, + data=xml_str, + params=session_params, timeout=timeout_sec) + if result.status_code != 200 and result.status_code != 207: + if result.status_code == 401: + print("WARN: get_method " + url + ' rejected by secure mode') + elif result.status_code == 403: + print('WARN: get_method Forbidden url: ' + url) + elif result.status_code == 404: + print('WARN: get_method Not Found url: ' + url) + elif result.status_code == 410: + print('WARN: get_method no longer available url: ' + url) + else: + session_headers2 = session_headers.copy() + if session_headers2.get('Authorization'): + session_headers2['Authorization'] = 'REDACTED' + print('WARN: get_method url: ' + url + + ' failed with error code ' + + str(result.status_code) + + ' headers: ' + str(session_headers2)) + return result.content.decode('utf-8') + except requests.exceptions.RequestException as ex: + session_headers2 = session_headers.copy() + if session_headers2.get('Authorization'): + session_headers2['Authorization'] = 'REDACTED' + if debug and not quiet: + print('EX: get_method failed, url: ' + str(url) + ', ' + + 'headers: ' + str(session_headers2) + ', ' + + 'params: ' + str(session_params) + ', ' + str(ex)) + except ValueError as ex: + session_headers2 = session_headers.copy() + if session_headers2.get('Authorization'): + session_headers2['Authorization'] = 'REDACTED' + if debug and not quiet: + print('EX: get_method failed, url: ' + str(url) + ', ' + + 'headers: ' + str(session_headers2) + ', ' + + 'params: ' + str(session_params) + ', ' + str(ex)) + except SocketError as ex: + if not quiet: + if ex.errno == errno.ECONNRESET: + print('EX: get_method failed, ' + + 'connection was reset during get_vcard ' + str(ex)) + return None diff --git a/tests.py b/tests.py index a1c572ace..b0a21d116 100644 --- a/tests.py +++ b/tests.py @@ -176,6 +176,9 @@ from shares import send_share_via_server from shares import get_shared_items_catalog_via_server from blocking import load_cw_lists from blocking import add_cw_from_lists +from happening import dav_month_via_server +from happening import dav_day_via_server + TEST_SERVER_GROUP_RUNNING = False TEST_SERVER_ALICE_RUNNING = False @@ -2946,6 +2949,13 @@ def test_client_to_server(base_dir: str): time.sleep(1) + # set bob to be following the calendar of alice + print('Bob follows the calendar of Alice') + following_cal_path = \ + bob_dir + '/accounts/bob@' + bob_domain + '/followingCalendar.txt' + with open(following_cal_path, 'w+') as fp: + fp.write('alice@' + alice_domain + '\n') + print('\n\n*******************************************************') print('EVENT: Alice sends to Bob via c2s') @@ -2981,6 +2991,12 @@ def test_client_to_server(base_dir: str): if os.path.isfile(os.path.join(bob_outbox_path, name))]) == 0 print('EVENT: all inboxes and outboxes are empty') signing_priv_key_pem = None + test_date = datetime.datetime.now() + event_date = \ + str(test_date.year) + '-' + str(test_date.month) + '-' + \ + str(test_date.day) + event_time = '11:45' + location = "Kinshasa" send_result = \ send_post_via_server(signing_priv_key_pem, __version__, alice_dir, session_alice, 'alice', password, @@ -2993,6 +3009,7 @@ def test_client_to_server(base_dir: str): cached_webfingers, person_cache, is_article, system_language, languages_understood, low_bandwidth, content_license_url, + event_date, event_time, location, True, None, None, conversation_id, None) print('send_result: ' + str(send_result)) @@ -3028,6 +3045,17 @@ def test_client_to_server(base_dir: str): if os.path.isfile(os.path.join(bob_outbox_path, name))]) == 0 print(">>> s2s post arrived in Bob's inbox") + + calendar_path = bob_dir + '/accounts/bob@' + bob_domain + '/calendar' + if not os.path.isdir(calendar_path): + print('Missing calendar path: ' + calendar_path) + assert os.path.isdir(calendar_path) + assert os.path.isdir(calendar_path + '/' + str(test_date.year)) + assert os.path.isfile(calendar_path + '/' + str(test_date.year) + '/' + + str(test_date.month) + '.txt') + print(">>> calendar entry created for s2s post which arrived at " + + "Bob's inbox") + print("c2s send success\n\n\n") print('\n\nEVENT: Getting message id for the post') @@ -3147,6 +3175,35 @@ def test_client_to_server(base_dir: str): show_test_boxes('bob', bob_inbox_path, bob_outbox_path) assert len([name for name in os.listdir(alice_inbox_path) if os.path.isfile(os.path.join(alice_inbox_path, name))]) == 0 + + print('\n\nEVENT: Bob checks his calendar via caldav') + # test caldav result for a month + result = \ + dav_month_via_server(session_bob, http_prefix, + 'bob', bob_domain, bob_port, True, + test_date.year, test_date.month, + 'bobpass') + print('response: ' + str(result)) + assert 'VCALENDAR' in str(result) + assert 'VEVENT' in str(result) + # test caldav result for a day + result = \ + dav_day_via_server(session_bob, http_prefix, + 'bob', bob_domain, bob_port, True, + test_date.year, test_date.month, + test_date.day, 'bobpass') + print('response: ' + str(result)) + assert 'VCALENDAR' in str(result) + assert 'VEVENT' in str(result) + # test for incorrect caldav login + result = \ + dav_day_via_server(session_bob, http_prefix, + 'bob', bob_domain, bob_port, True, + test_date.year, test_date.month, + test_date.day, 'wrongpass') + assert 'VCALENDAR' not in str(result) + assert 'VEVENT' not in str(result) + print('\n\nEVENT: Bob likes the post') send_like_via_server(bob_dir, session_bob, 'bob', 'bobpass', diff --git a/webapp_calendar.py b/webapp_calendar.py index 38a3afa86..576fae8e3 100644 --- a/webapp_calendar.py +++ b/webapp_calendar.py @@ -267,6 +267,7 @@ def html_calendar(person_cache: {}, css_cache: {}, translate: {}, """ domain = remove_domain_port(domain_full) + text_match = '' default_year = 1970 default_month = 0 month_number = default_month @@ -320,11 +321,13 @@ def html_calendar(person_cache: {}, css_cache: {}, translate: {}, year, month_number, day_number, person_cache, - http_prefix) + http_prefix, + text_match) day_events = None events = \ get_todays_events(base_dir, nickname, domain, - year, month_number, day_number) + year, month_number, day_number, + text_match) if events: if events.get(str(day_number)): day_events = events[str(day_number)] @@ -337,10 +340,11 @@ def html_calendar(person_cache: {}, css_cache: {}, translate: {}, if icalendar: return get_month_events_icalendar(base_dir, nickname, domain, year, month_number, person_cache, - http_prefix) + http_prefix, text_match) events = \ - get_calendar_events(base_dir, nickname, domain, year, month_number) + get_calendar_events(base_dir, nickname, domain, year, month_number, + text_match) prev_year = year prev_month_number = month_number - 1 diff --git a/webapp_media.py b/webapp_media.py index 758c6ec95..db642168b 100644 --- a/webapp_media.py +++ b/webapp_media.py @@ -54,36 +54,40 @@ def _add_embedded_video_from_sites(translate: {}, content: str, if '"' + video_site in content: url = content.split('"' + video_site)[1] if '"' in url: - url = url.split('"')[0].replace('/watch?v=', '/embed/') - if '&' in url: - url = url.split('&')[0] - if '?utm_' in url: - url = url.split('?utm_')[0] - content += \ - "
\n\n
\n" - return content + url = url.split('"')[0] + if '/channel/' not in url: + url = url.replace('/watch?v=', '/embed/') + if '&' in url: + url = url.split('&')[0] + if '?utm_' in url: + url = url.split('?utm_')[0] + content += \ + "
\n\n
\n" + return content video_site = 'https://youtu.be/' if '"' + video_site in content: url = content.split('"' + video_site)[1] if '"' in url: - url = 'embed/' + url.split('"')[0] - if '&' in url: - url = url.split('&')[0] - if '?utm_' in url: - url = url.split('?utm_')[0] - video_site = 'https://www.youtube.com/' - content += \ - "
\n\n
\n" - return content + url = url.split('"')[0] + if '/channel/' not in url: + url = 'embed/' + url + if '&' in url: + url = url.split('&')[0] + if '?utm_' in url: + url = url.split('?utm_')[0] + video_site = 'https://www.youtube.com/' + content += \ + "
\n\n
\n" + return content invidious_sites = ( 'https://invidious.snopyta.org', diff --git a/webfinger.py b/webfinger.py index 440d96b3e..0393c1140 100644 --- a/webfinger.py +++ b/webfinger.py @@ -316,8 +316,9 @@ def _webfinger_update_vcard(wf_json: {}, actor_json: {}) -> bool: """Updates the vcard link """ for link in wf_json['links']: - if link['type'] == 'text/vcard': - return False + if link.get('type'): + if link['type'] == 'text/vcard': + return False wf_json['links'].append({ "href": actor_json['url'], "rel": "http://webfinger.net/rel/profile-page",