diff options
Diffstat (limited to 'contrib/caldav')
-rw-r--r-- | contrib/caldav/README.md | 13 | ||||
-rwxr-xr-x | contrib/caldav/calcurse-caldav.py | 242 | ||||
-rw-r--r-- | contrib/caldav/config.sample | 14 | ||||
-rwxr-xr-x | contrib/caldav/hooks/post-sync | 2 |
4 files changed, 148 insertions, 123 deletions
diff --git a/contrib/caldav/README.md b/contrib/caldav/README.md index a842081..b464dc9 100644 --- a/contrib/caldav/README.md +++ b/contrib/caldav/README.md @@ -34,13 +34,12 @@ argument. You can choose between the following initialization modes: For subsequent calcurse-caldav invocations, you don't need to specify any additional parameters. -You can specify a username and password for basic authentication in the -config file. Alternatively, the password can be passed securely from another -program (such as *pass*) via the `CALCURSE_CALDAV_PASSWORD` environment variable like -so: -``` -CALCURSE_CALDAV_PASSWORD=$(pass show calcurse) calcurse-caldav -``` +Specify your HTTP Basic authentication credentials under the config file's +`Auth` section. The most secure approach is to save your password in a CLI +encrypted password store (_e.g.,_ [pass](https://www.passwordstore.org/)), and +then set `PasswordCommand` to the shell command used to retrieve it. +If security is not a priority, you may store your password in plain text +instead. Hooks ----- diff --git a/contrib/caldav/calcurse-caldav.py b/contrib/caldav/calcurse-caldav.py index 7c9b89b..5efd306 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 isinstance(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,58 @@ 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').rstrip('\n') 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 +743,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 +806,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() diff --git a/contrib/caldav/config.sample b/contrib/caldav/config.sample index c89cfb7..0ba8fa8 100644 --- a/contrib/caldav/config.sample +++ b/contrib/caldav/config.sample @@ -11,10 +11,12 @@ # Path to the calcurse binary that is used for importing/exporting items. Binary = calcurse -# Host name of the server that hosts CalDAV. +# Host name of the server that hosts CalDAV. Do NOT prepend a protocol prefix, +# such as http:// or https://. Append :<port> for a port other than 80. Hostname = some.hostname.com -# Path to the CalDAV calendar on the host specified above. +# Path to the CalDAV calendar on the host specified above. This is the base +# path following your host name in the URL. Path = /path/to/calendar/on/the/server/ # Type of authentication to use. Must be "basic" or "oauth2" @@ -46,11 +48,13 @@ DryRun = Yes # Enable this if you want detailed logs written to stdout. Verbose = Yes -# Credentials for HTTP Basic Authentication. Leave this commented out if you do -# not want to use authentication. +# Credentials for HTTP Basic Authentication (if required). +# Set `Password` to your password in plaintext (unsafe), +# or `PasswordCommand` to a shell command that retrieves it (recommended). #[Auth] #Username = user -#Password = pass +#Password = password +#PasswordCommand = pass baikal # Optionally specify additional HTTP headers here. #[CustomHeaders] diff --git a/contrib/caldav/hooks/post-sync b/contrib/caldav/hooks/post-sync index 62ebe11..db0059b 100755 --- a/contrib/caldav/hooks/post-sync +++ b/contrib/caldav/hooks/post-sync @@ -26,7 +26,7 @@ commit_dir() { if [ -d .git ] && command -v git >/dev/null; then git add "$@" if ! git diff-index --quiet --cached HEAD; then - git commit -m "Automatic commit by the post-save hook" + git commit -m "Automatic commit by the post-sync hook" fi fi } |