diff options
Diffstat (limited to 'contrib/caldav/calcurse-caldav.py')
-rwxr-xr-x | contrib/caldav/calcurse-caldav.py | 241 |
1 files changed, 131 insertions, 110 deletions
diff --git a/contrib/caldav/calcurse-caldav.py b/contrib/caldav/calcurse-caldav.py index 7c9b89b..f9488e6 100755 --- a/contrib/caldav/calcurse-caldav.py +++ b/contrib/caldav/calcurse-caldav.py @@ -3,25 +3,92 @@ import argparse import base64 import configparser -import httplib2 -import pathlib import os +import pathlib import re +import shlex import subprocess import sys import textwrap -import urllib.parse import xml.etree.ElementTree as etree +import httplib2 + # Optional libraries for OAuth2 authentication try: - from oauth2client.client import OAuth2WebServerFlow, HttpAccessTokenRefreshError - from oauth2client.file import Storage import webbrowser + + from oauth2client.client import HttpAccessTokenRefreshError, OAuth2WebServerFlow + from oauth2client.file import Storage except ModuleNotFoundError: pass +class Config: + _map = {} + + def __init__(self, fn): + self._map = { + 'Auth': { + 'Password': None, + 'PasswordCommand': None, + 'Username': None, + }, + 'CustomHeaders': {}, + 'General': { + 'AuthMethod': 'basic', + 'Binary': 'calcurse', + 'Debug': False, + 'DryRun': True, + 'HTTPS': True, + 'Hostname': None, + 'InsecureSSL': False, + 'Path': None, + 'SyncFilter': 'cal,todo', + 'Verbose': False, + }, + 'OAuth2': { + 'ClientID': None, + 'ClientSecret': None, + 'RedirectURI': 'http://127.0.0.1', + 'Scope': None, + }, + } + + config = configparser.RawConfigParser() + config.optionxform = str + if verbose: + print('Loading configuration from ' + configfn + '...') + try: + config.read_file(open(fn)) + except FileNotFoundError: + die('Configuration file not found: {}'.format(fn)) + + for sec in config.sections(): + if sec not in self._map: + die('Unexpected config section: {}'.format(sec)) + + if not self._map[sec]: + # Import section with custom key-value pairs. + self._map[sec] = dict(config.items(sec)) + continue + + # Import section with predefined keys. + for key, val in config.items(sec): + if key not in self._map[sec]: + die('Unexpected config key in section {}: {}'.format(sec, key)) + if type(self._map[sec][key]) == bool: + self._map[sec][key] = config.getboolean(sec, key) + else: + self._map[sec][key] = val + + def section(self, section): + return self._map[section] + + def get(self, section, key): + return self._map[section][key] + + def msgfmt(msg, prefix=''): lines = [] for line in msg.splitlines(): @@ -43,6 +110,7 @@ def check_dir(dir): except FileExistsError: die("{} is not a directory".format(dir)) + def die_atnode(msg, node): if debug: msg += '\n\n' @@ -147,8 +215,8 @@ def calcurse_version(): def get_auth_headers(): if not username or not password: return {} - user_password = ('{}:{}'.format(username, password)).encode('ascii') - user_password = base64.b64encode(user_password).decode('ascii') + user_password = ('{}:{}'.format(username, password)).encode('utf-8') + user_password = base64.b64encode(user_password).decode('utf-8') headers = {'Authorization': 'Basic {}'.format(user_password)} return headers @@ -353,18 +421,21 @@ def push_object(conn, objhash): if not headers: return None - - etag = None headerdict = dict(headers) - if 'etag' in headerdict: - etag = headerdict['etag'] - while not etag: + + # Retrieve href from server to match server-side format. Retrieve ETag + # unless it can be extracted from the PUT response already. + ret_href, ret_etag = None, headerdict.get('etag') + while not ret_etag or not ret_href: etagdict = get_etags(conn, [href]) - if etagdict: - etag = next(iter(etagdict.values())) - etag = etag.strip('"') + if not etagdict: + continue + ret_href, new_etag = next(iter(etagdict.items())) + # Favor ETag from PUT response to avoid race condition. + if not ret_etag: + ret_etag = new_etag - return (urllib.parse.quote(href), etag) + return (ret_href, ret_etag.strip('"')) def push_objects(objhashes, conn, syncdb, etagdict): @@ -588,105 +659,57 @@ verbose = args.verbose debug = args.debug debug_raw = args.debug_raw -# Read environment variables -password = os.getenv('CALCURSE_CALDAV_PASSWORD') - # Read configuration. -config = configparser.RawConfigParser() -if verbose: - print('Loading configuration from ' + configfn + '...') -try: - config.read_file(open(configfn)) -except FileNotFoundError as e: - die('Configuration file not found: {}'.format(configfn)) - -if config.has_option('General', 'InsecureSSL'): - insecure_ssl = config.getboolean('General', 'InsecureSSL') +config = Config(configfn) + +authmethod = config.get('General', 'AuthMethod').lower() +calcurse = [config.get('General', 'Binary')] +debug = debug or config.get('General', 'Debug') +dry_run = config.get('General', 'DryRun') +hostname = config.get('General', 'Hostname') +https = config.get('General', 'HTTPS') +insecure_ssl = config.get('General', 'InsecureSSL') +path = config.get('General', 'Path') +sync_filter = config.get('General', 'SyncFilter') +verbose = verbose or config.get('General', 'Verbose') + +if os.getenv('CALCURSE_CALDAV_PASSWORD'): + # This approach is deprecated, but preserved for backwards compatibility + password = os.getenv('CALCURSE_CALDAV_PASSWORD') +elif config.get('Auth', 'Password'): + password = config.get('Auth', 'Password') +elif config.get('Auth', 'PasswordCommand'): + tokenized_cmd = shlex.split(config.get('Auth', 'PasswordCommand')) + password = subprocess.run(tokenized_cmd, capture_output=True).stdout.decode('UTF-8') else: - insecure_ssl = False + password = None -# Read config for "HTTPS" option (default=True) -if config.has_option('General', 'HTTPS'): - https = config.getboolean('General', 'HTTPS') -else: - https = True +username = config.get('Auth', 'Username') -if config.has_option('General', 'Binary'): - calcurse = [config.get('General', 'Binary')] -else: - calcurse = ['calcurse'] +client_id = config.get('OAuth2', 'ClientID') +client_secret = config.get('OAuth2', 'ClientSecret') +redirect_uri = config.get('OAuth2', 'RedirectURI') +scope = config.get('OAuth2', 'Scope') + +custom_headers = config.section('CustomHeaders') +# Append data directory to calcurse command. if datadir: check_dir(datadir) calcurse += ['-D', datadir] -if config.has_option('General', 'DryRun'): - dry_run = config.getboolean('General', 'DryRun') -else: - dry_run = True - -if not verbose and config.has_option('General', 'Verbose'): - verbose = config.getboolean('General', 'Verbose') - -if not debug and config.has_option('General', 'Debug'): - debug = config.getboolean('General', 'Debug') - -if config.has_option('General', 'AuthMethod'): - authmethod = config.get('General', 'AuthMethod').lower() -else: - authmethod = 'basic' - -if config.has_option('General', 'SyncFilter'): - sync_filter = config.get('General', 'SyncFilter') - - invalid_filter_values = validate_sync_filter() - - if len(invalid_filter_values): - die('Invalid value(s) in SyncFilter option: ' + ', '.join(invalid_filter_values)) -else: - sync_filter = 'cal,todo' - -if config.has_option('Auth', 'UserName'): - username = config.get('Auth', 'UserName') -else: - username = None - -if config.has_option('Auth', 'Password') and not password: - password = config.get('Auth', 'Password') - -if config.has_section('CustomHeaders'): - custom_headers = dict(config.items('CustomHeaders')) -else: - custom_headers = {} - -if config.has_option('OAuth2', 'ClientID'): - client_id = config.get('OAuth2', 'ClientID') -else: - client_id = None - -if config.has_option('OAuth2', 'ClientSecret'): - client_secret = config.get('OAuth2', 'ClientSecret') -else: - client_secret = None - -if config.has_option('OAuth2', 'Scope'): - scope = config.get('OAuth2', 'Scope') -else: - scope = None - -if config.has_option('OAuth2', 'RedirectURI'): - redirect_uri = config.get('OAuth2', 'RedirectURI') -else: - redirect_uri = 'http://127.0.0.1' - -# Change URl prefix according to HTTP/HTTPS -if https: - urlprefix = "https://" -else: - urlprefix = "http://" - -hostname = config.get('General', 'HostName') -path = '/' + config.get('General', 'Path').strip('/') + '/' +# Validate sync filter. +invalid_filter_values = validate_sync_filter() +if len(invalid_filter_values): + die('Invalid value(s) in SyncFilter option: ' + ', '.join(invalid_filter_values)) + +# Ensure host name and path are defined and initialize *_uri. +if not hostname: + die('Hostname missing in configuration.') +if not path: + die('Path missing in configuration.') +urlprefix = "https://" if https else "http://" +path = '/{}/'.format(path.strip('/')) hostname_uri = urlprefix + hostname absolute_uri = hostname_uri + path @@ -719,9 +742,7 @@ try: # Connect to the server. if verbose: print('Connecting to ' + hostname + '...') - conn = httplib2.Http() - if insecure_ssl: - conn.disable_ssl_certificate_validation = True + conn = httplib2.Http(disable_ssl_certificate_validation=insecure_ssl) if authmethod == 'oauth2': # Authenticate with OAuth2 and authorize HTTP object @@ -784,7 +805,7 @@ try: # Write the synchronization database. save_syncdb(syncdbfn, syncdb) - #Clear OAuth2 credentials if used + # Clear OAuth2 credentials if used. if authmethod == 'oauth2': conn.clear_credentials() |