From 552cf06fb69cefb4ded63ce8e1abf1c4b0686b86 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 23 Feb 2022 09:51:00 +0000 Subject: [PATCH] Beginning of caldav integration --- daemon.py | 125 ++++++++++++++++ happening.py | 334 ++++++++++++++++++++++++++++++++++++++++++- tests.py | 4 + translations/ar.json | 4 +- translations/ca.json | 4 +- translations/cy.json | 4 +- translations/de.json | 4 +- translations/en.json | 4 +- translations/es.json | 4 +- translations/fr.json | 4 +- translations/ga.json | 4 +- translations/hi.json | 4 +- translations/it.json | 4 +- translations/ja.json | 4 +- translations/ku.json | 4 +- translations/oc.json | 4 +- translations/pt.json | 4 +- translations/ru.json | 4 +- translations/sw.json | 4 +- translations/zh.json | 4 +- 20 files changed, 513 insertions(+), 18 deletions(-) diff --git a/daemon.py b/daemon.py index 603930312..a80393251 100644 --- a/daemon.py +++ b/daemon.py @@ -341,6 +341,10 @@ from schedule import run_post_schedule_watchdog from schedule import remove_scheduled_posts from outbox import post_message_to_outbox from happening import remove_calendar_event +from happening import dav_propfind_response +from happening import dav_put_response +from happening import dav_report_response +from happening import dav_delete_response from bookmarks import bookmark_post from bookmarks import undo_bookmark_post from petnames import set_pet_name @@ -975,6 +979,16 @@ class PubServer(BaseHTTPRequestHandler): 'This is nothing less ' + 'than an utter triumph') + 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) + else: + self._http_return_code(207, 'Multi Status', + 'Lots of things') + def _403(self) -> None: if self.server.translate: self._http_return_code(403, self.server.translate['Forbidden'], @@ -16801,6 +16815,117 @@ class PubServer(BaseHTTPRequestHandler): '_GET', 'end benchmarks', self.server.debug) + def _dav_handler(self, endpoint_type: str): + 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: + self._400() + return + if not self.headers.get('Content-length'): + print(endpoint_type.upper() + ' has no content-length') + self._400() + return + length = int(self.headers['Content-length']) + if length > self.server.max_post_length: + print(endpoint_type.upper() + + ' request size too large ' + self.path) + self._400() + return + if not self.path.startswith('/calendars/'): + print(endpoint_type.upper() + ' without /calendars ' + self.path) + self._404() + return + if not self._is_authorized(): + print('PROPFIND Not authorized') + self._403() + return + nickname = self.path.split('/calendars/')[1] + if '/' in nickname: + nickname = nickname.split('/')[0] + if not nickname: + print(endpoint_type.upper() + ' no nickname ' + self.path) + self._400() + return + if not os.path.isdir(self.server.base_dir + '/accounts/' + + nickname + '@' + self.server.domain): + print(endpoint_type.upper() + + ' for non-existent account ' + self.path) + self._404() + return + propfind_bytes = None + try: + propfind_bytes = self.rfile.read(length) + except SocketError as ex: + if ex.errno == errno.ECONNRESET: + print('EX: PROPFIND connection reset by peer') + else: + print('EX: PROPFIND socket error') + self._400() + return + except ValueError as ex: + print('EX: ' + endpoint_type.upper() + + ' rfile.read failed, ' + str(ex)) + self._400() + return + if not propfind_bytes: + self._404() + return + depth = 0 + if self.headers.get('Depth'): + depth = self.headers['Depth'] + propfind_xml = propfind_bytes.decode('utf-8') + response_str = None + if endpoint_type == 'propfind': + response_str = dav_propfind_response(self.server.base_dir, + nickname, self.server.domain, + depth, propfind_xml) + elif endpoint_type == 'put': + response_str = dav_put_response(self.server.base_dir, + nickname, self.server.domain, + depth, propfind_xml, + self.server.http_prefix, + self.server.system_language) + elif endpoint_type == 'report': + response_str = dav_report_response(self.server.base_dir, + nickname, self.server.domain, + depth, propfind_xml) + elif endpoint_type == 'delete': + response_str = \ + dav_delete_response(self.server.base_dir, + nickname, self.server.domain, + depth, self.path, + self.server.http_prefix, + self.server.debug, + self.server.recent_posts_cache) + if not response_str: + self._404() + return + if response_str != 'Ok': + message_xml = response_str.encode('utf-8') + message_xml_len = len(message_xml) + self._set_headers('application/xml; charset=utf-8', + message_xml_len, + None, calling_domain, False) + self._write(message_xml) + if 'multistatus' in response_str: + return self._207() + self._200() + + def do_PROPFIND(self): + self._dav_handler('propfind') + + def do_PUT(self): + self._dav_handler('put') + + def do_REPORT(self): + self._dav_handler('report') + + def do_DELETE(self): + self._dav_handler('delete') + def do_HEAD(self): calling_domain = self.server.domain_full if self.headers.get('Host'): diff --git a/happening.py b/happening.py index 237e5e4a3..6b24bd1f8 100644 --- a/happening.py +++ b/happening.py @@ -20,6 +20,10 @@ from utils import has_object_dict from utils import acct_dir from utils import remove_html from utils import get_display_name +from utils import delete_post +from utils import get_status_number +from filters import is_filtered +from context import get_individual_post_context def _valid_uuid(test_uuid: str, version: int): @@ -274,6 +278,14 @@ def _ical_date_string(date_str: str) -> str: return date_str.replace(' ', '') +def _dav_encode_token(year: int, month_number: int, + message_id: str) -> str: + """Returns a token corresponding to a calendar event + """ + return str(year) + '_' + str(month_number) + '_' + \ + message_id.replace('/', '--').replace('#', '--') + + def _icalendar_day(base_dir: str, nickname: str, domain: str, day_events: [], person_cache: {}, http_prefix: str) -> str: @@ -357,10 +369,14 @@ def _icalendar_day(base_dir: str, nickname: str, domain: str, event_end = \ _ical_date_string(event_end.strftime("%Y-%m-%dT%H:%M:%SZ")) + token_year = int(event_start.split('-')[0]) + token_month_number = int(event_start.split('-')[1]) + uid = _dav_encode_token(token_year, token_month_number, post_id) + ical_str += \ 'BEGIN:VEVENT\n' + \ 'DTSTAMP:' + published + '\n' + \ - 'UID:' + post_id + '\n' + \ + 'UID:' + uid + '\n' + \ 'DTSTART:' + event_start + '\n' + \ 'DTEND:' + event_end + '\n' + \ 'STATUS:CONFIRMED\n' @@ -677,3 +693,319 @@ def remove_calendar_event(base_dir: str, nickname: str, domain: str, fp_cal.write(line) except OSError: print('EX: unable to write ' + calendar_filename) + + +def _dav_decode_token(token: str) -> (int, int, str): + """Decodes a token corresponding to a calendar event + """ + if '_' not in token or '--' not in token: + return None, None, None + token_sections = token.split('_') + if len(token_sections) != 3: + return None, None, None + if not token_sections[0].isdigit(): + return None, None, None + if not token_sections[1].isdigit(): + return None, None, None + token_year = int(token_sections[0]) + token_month_number = int(token_sections[1]) + token_post_id = token_sections[2].replace('--', '/') + return token_year, token_month_number, token_post_id + + +def dav_propfind_response(base_dir: str, nickname: str, domain: str, + depth: int, xml_str: str) -> str: + """Returns the response to caldav PROPFIND + """ + if '' not in xml_str: + return None + response_str = \ + '\n' + \ + ' \n' + \ + ' /calendars/' + nickname + '/\n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' HTTP/1.1 200 OK\n' + \ + ' \n' + \ + ' \n' + \ + '' + return response_str + + +def _dav_store_event(base_dir: str, nickname: str, domain: str, + event_list: [], http_prefix: str, + system_language: str) -> bool: + """Stores a calendar event obtained via caldav PUT + """ + event_str = str(event_list) + if 'DTSTAMP:' not in event_str or \ + 'DTSTART:' not in event_str or \ + 'DTEND:' not in event_str: + return False + if 'STATUS:' not in event_str and 'DESCRIPTION:' not in event_str: + return False + + timestamp = None + start_time = None + end_time = None + description = None + for line in event_list: + if line.startswith('DTSTAMP:'): + timestamp = line.split(':', 1)[1] + elif line.startswith('DTSTART:'): + start_time = line.split(':', 1)[1] + elif line.startswith('DTEND:'): + end_time = line.split(':', 1)[1] + elif line.startswith('SUMMARY:') or line.startswith('DESCRIPTION:'): + description = line.split(':', 1)[1] + elif line.startswith('LOCATION:'): + location = line.split(':', 1)[1] + + if not timestamp or \ + not start_time or \ + not end_time or \ + not description: + return False + if len(timestamp) < 15: + return False + if len(start_time) < 15: + return False + if len(end_time) < 15: + return False + + # check that the description is valid + if is_filtered(base_dir, nickname, domain, description): + 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(): + return False + if int(timestamp_year) < 2020 or int(timestamp_year) > 2100: + 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(): + 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() + # create the status number from DTSTAMP + status_number, published = get_status_number(published) + # get the post id + actor = http_prefix + "://" + domain + "/users/" + nickname + actor2 = http_prefix + "://" + domain + "/@" + nickname + post_id = actor + "/statuses/" + status_number + + next_str = post_id + "/replies?only_other_accounts=true&page=true" + content = \ + '

@' + nickname + \ + '' + remove_html(description) + '

' + event_json = { + "@context": post_context, + "id": post_id + "/activity", + "type": "Create", + "actor": actor, + "published": published, + "to": [actor], + "cc": [], + "object": { + "id": post_id, + "conversation": post_id, + "type": "Note", + "summary": None, + "inReplyTo": None, + "published": published, + "url": actor + "/" + status_number, + "attributedTo": actor, + "to": [actor], + "cc": [], + "sensitive": False, + "atomUri": post_id, + "inReplyToAtomUri": None, + "commentsEnabled": False, + "rejectReplies": True, + "mediaType": "text/html", + "content": content, + "contentMap": { + system_language: content + }, + "attachment": [], + "tag": [ + { + "href": actor2, + "name": "@" + nickname + "@" + domain, + "type": "Mention" + }, + { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Event", + "name": content, + "startTime": start_time, + "endTime": end_time + } + ], + "replies": { + "id": post_id + "/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": next_str, + "partOf": post_id + "/replies", + "items": [] + } + } + } + } + if location: + event_json['object']['tag'].append({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Place", + "name": location + }) + handle = nickname + '@' + domain + outbox_dir = base_dir + '/accounts/' + handle + '/outbox' + if not os.path.isdir(outbox_dir): + return False + filename = outbox_dir + '/' + post_id.replace('/', '#') + '.json' + save_json(event_json, filename) + save_event_post(base_dir, handle, post_id, event_json) + + return True + + +def dav_put_response(base_dir: str, nickname: str, domain: str, + depth: int, xml_str: str, http_prefix: str, + system_language: str) -> str: + """Returns the response to caldav PUT + """ + if '\n' not in xml_str: + return None + if 'BEGIN:VCALENDAR' not in xml_str or \ + 'END:VCALENDAR' not in xml_str: + return None + if 'BEGIN:VEVENT' not in xml_str or \ + 'END:VEVENT' not in xml_str: + return None + + stored_count = 0 + reading_event = False + lines_list = xml_str.split('\n') + event_list = [] + for line in lines_list: + line = line.strip() + if not reading_event: + if line == 'BEGIN:VEVENT': + reading_event = True + event_list = [] + else: + if line == 'END:VEVENT': + if event_list: + _dav_store_event(base_dir, nickname, domain, + event_list, http_prefix, + system_language) + stored_count += 1 + reading_event = False + else: + event_list.append(line) + if stored_count == 0: + return None + return 'Ok' + + +def dav_report_response(base_dir: str, nickname: str, domain: str, + depth: int, xml_str: str) -> str: + """Returns the response to caldav REPORT + """ + if '' not in xml_str: + if '' not in xml_str: + return None + # TODO + return None + + +def dav_delete_response(base_dir: str, nickname: str, domain: str, + depth: int, path: str, + http_prefix: str, debug: bool, + recent_posts_cache: {}) -> str: + """Returns the response to caldav DELETE + """ + token = path.split('/calendars/' + nickname + '/')[1] + token_year, token_month_number, token_post_id = \ + _dav_decode_token(token) + if not token_year: + return None + post_filename = locate_post(base_dir, nickname, domain, token_post_id) + if not post_filename: + print('Calendar post not found ' + token_post_id) + return None + post_json_object = load_json(post_filename) + if not _is_happening_post(post_json_object): + print(token_post_id + ' is not a calendar post') + return None + remove_calendar_event(base_dir, nickname, domain, + token_year, token_month_number, + token_post_id) + delete_post(base_dir, http_prefix, + nickname, domain, post_filename, + debug, recent_posts_cache) + return 'Ok' diff --git a/tests.py b/tests.py index 5beb7702b..a1c572ace 100644 --- a/tests.py +++ b/tests.py @@ -4956,6 +4956,10 @@ def _test_functions(): 'do_GET', 'do_POST', 'do_HEAD', + 'do_PROPFIND', + 'do_PUT', + 'do_REPORT', + 'do_DELETE', '__run', '_send_to_named_addresses', 'globaltrace', diff --git a/translations/ar.json b/translations/ar.json index 68926de5d..5ab1eb100 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -506,5 +506,7 @@ "Write your news report": "اكتب تقريرك الإخباري", "Dyslexic font": "الخط المعسر القراءة", "Leave a comment": "اترك تعليقا", - "View comments": "تعليقات عرض" + "View comments": "تعليقات عرض", + "Multi Status": "متعدد الحالات", + "Lots of things": "أشياء كثيرة" } diff --git a/translations/ca.json b/translations/ca.json index ecc8834cf..af52432a4 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -506,5 +506,7 @@ "Write your news report": "Escriu la teva notícia", "Dyslexic font": "Tipus de lletra dislèxic", "Leave a comment": "Deixa un comentari", - "View comments": "Veure comentaris" + "View comments": "Veure comentaris", + "Multi Status": "Estat múltiple", + "Lots of things": "Moltes coses" } diff --git a/translations/cy.json b/translations/cy.json index 523f22f64..9319a457a 100644 --- a/translations/cy.json +++ b/translations/cy.json @@ -506,5 +506,7 @@ "Write your news report": "Ysgrifennwch eich adroddiad newyddion", "Dyslexic font": "Ffont dyslecsig", "Leave a comment": "Gadael sylw", - "View comments": "Gweld sylwadau" + "View comments": "Gweld sylwadau", + "Multi Status": "Statws Aml", + "Lots of things": "Llawer o pethau" } diff --git a/translations/de.json b/translations/de.json index 330719c08..cc777a8f0 100644 --- a/translations/de.json +++ b/translations/de.json @@ -506,5 +506,7 @@ "Write your news report": "Schreiben Sie Ihren Nachrichtenbericht", "Dyslexic font": "Schriftart für Legastheniker", "Leave a comment": "Hinterlasse einen Kommentar", - "View comments": "Kommentare ansehen" + "View comments": "Kommentare ansehen", + "Multi Status": "Multi-Status", + "Lots of things": "Viele Dinge" } diff --git a/translations/en.json b/translations/en.json index 8987d97b5..4572a3c3c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -506,5 +506,7 @@ "Write your news report": "Write your news report", "Dyslexic font": "Dyslexic font", "Leave a comment": "Leave a comment", - "View comments": "View comments" + "View comments": "View comments", + "Multi Status": "Multi Status", + "Lots of things": "Lots of things" } diff --git a/translations/es.json b/translations/es.json index 8ba1e78ab..2635360ff 100644 --- a/translations/es.json +++ b/translations/es.json @@ -506,5 +506,7 @@ "Write your news report": "Escribe tu informe de noticias", "Dyslexic font": "Fuente disléxica", "Leave a comment": "Deja un comentario", - "View comments": "Ver comentarios" + "View comments": "Ver comentarios", + "Multi Status": "Estado múltiple", + "Lots of things": "Muchas cosas" } diff --git a/translations/fr.json b/translations/fr.json index 33705a762..c0a03dde5 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -506,5 +506,7 @@ "Write your news report": "Rédigez votre reportage", "Dyslexic font": "Police dyslexique", "Leave a comment": "Laissez un commentaire", - "View comments": "Voir les commentaires" + "View comments": "Voir les commentaires", + "Multi Status": "Statut multiple", + "Lots of things": "Beaucoup de choses" } diff --git a/translations/ga.json b/translations/ga.json index b8815bde2..27a56bad3 100644 --- a/translations/ga.json +++ b/translations/ga.json @@ -506,5 +506,7 @@ "Write your news report": "Scríobh do thuairisc nuachta", "Dyslexic font": "Cló disléicseach", "Leave a comment": "Fág trácht", - "View comments": "Féach ar thuairimí" + "View comments": "Féach ar thuairimí", + "Multi Status": "Stádas Il", + "Lots of things": "A lán rudaí" } diff --git a/translations/hi.json b/translations/hi.json index f4df47b07..9f143ccfe 100644 --- a/translations/hi.json +++ b/translations/hi.json @@ -506,5 +506,7 @@ "Write your news report": "अपनी समाचार रिपोर्ट लिखें", "Dyslexic font": "डिस्लेक्सिक फ़ॉन्ट", "Leave a comment": "एक टिप्पणी छोड़ें", - "View comments": "टिप्पणियाँ देखें" + "View comments": "टिप्पणियाँ देखें", + "Multi Status": "बहु स्थिति", + "Lots of things": "बहुत सी बातें" } diff --git a/translations/it.json b/translations/it.json index f9d23ef67..336b7ae31 100644 --- a/translations/it.json +++ b/translations/it.json @@ -506,5 +506,7 @@ "Write your news report": "Scrivi il tuo reportage", "Dyslexic font": "Carattere dislessico", "Leave a comment": "Lascia un commento", - "View comments": "Visualizza commenti" + "View comments": "Visualizza commenti", + "Multi Status": "Stato multiplo", + "Lots of things": "Un sacco di cose" } diff --git a/translations/ja.json b/translations/ja.json index 49099f533..8427d5014 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -506,5 +506,7 @@ "Write your news report": "ニュースレポートを書く", "Dyslexic font": "失読症フォント", "Leave a comment": "コメントを残す", - "View comments": "コメントを見る" + "View comments": "コメントを見る", + "Multi Status": "マルチステータス", + "Lots of things": "多くの物" } diff --git a/translations/ku.json b/translations/ku.json index 4beb4e9f5..2c63fd059 100644 --- a/translations/ku.json +++ b/translations/ku.json @@ -506,5 +506,7 @@ "Write your news report": "Rapora xwe ya nûçeyan binivîsin", "Dyslexic font": "Font Dyslexic", "Leave a comment": "Bihêle şîroveyek", - "View comments": "Binêre şîroveyan" + "View comments": "Binêre şîroveyan", + "Multi Status": "Multi Status", + "Lots of things": "Gelek tişt" } diff --git a/translations/oc.json b/translations/oc.json index 9bc44b52e..e53a095a6 100644 --- a/translations/oc.json +++ b/translations/oc.json @@ -502,5 +502,7 @@ "Write your news report": "Write your news report", "Dyslexic font": "Dyslexic font", "Leave a comment": "Leave a comment", - "View comments": "View comments" + "View comments": "View comments", + "Multi Status": "Multi Status", + "Lots of things": "Lots of things" } diff --git a/translations/pt.json b/translations/pt.json index 0d7fec957..34f504d55 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -506,5 +506,7 @@ "Write your news report": "Escreva sua reportagem", "Dyslexic font": "Fonte disléxica", "Leave a comment": "Deixe um comentário", - "View comments": "Ver comentários" + "View comments": "Ver comentários", + "Multi Status": "Vários status", + "Lots of things": "Muitas coisas" } diff --git a/translations/ru.json b/translations/ru.json index 9050608c0..d79c2701f 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -506,5 +506,7 @@ "Write your news report": "Напишите свой новостной репортаж", "Dyslexic font": "Дислексический шрифт", "Leave a comment": "Оставить комментарий", - "View comments": "Посмотреть комментарии" + "View comments": "Посмотреть комментарии", + "Multi Status": "Мульти статус", + "Lots of things": "Много всего" } diff --git a/translations/sw.json b/translations/sw.json index d305f2446..f76346f65 100644 --- a/translations/sw.json +++ b/translations/sw.json @@ -506,5 +506,7 @@ "Write your news report": "Andika ripoti yako ya habari", "Dyslexic font": "Fonti ya Dyslexic", "Leave a comment": "Acha maoni", - "View comments": "Tazama maoni" + "View comments": "Tazama maoni", + "Multi Status": "Hali nyingi", + "Lots of things": "Mambo mengi" } diff --git a/translations/zh.json b/translations/zh.json index 06e1efc27..39725c4ca 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -506,5 +506,7 @@ "Write your news report": "写你的新闻报道", "Dyslexic font": "阅读障碍字体", "Leave a comment": "发表评论", - "View comments": "查看评论" + "View comments": "查看评论", + "Multi Status": "多状态", + "Lots of things": "很多事情" }