#!/usr/bin/env python3
# gmitodo - Gemini Shared Task List CGI
# Author: Simon Volpert <simon@simonvolpert.com>
# Project page: gemini://simonvolpert.com/gmitodo/
# This program is free software, released under the MIT license. See the LICENSE file for more information
import os
import sys
from pathlib import Path
import urllib.parse
# Possible state directory locations in order of decreasing priority
state_locations = [
Path('/etc/gmitodo'), # System-wide config directory
Path.home() / '.config/gmitodo', # User config directory
Path.cwd(), # Current working directory
Path('/tmp/gmitodo') # Transient directory
]
CONFIG_TEMPLATE = '''# gmitodo configuration file
# Set this option to "yes" to require authentication to view the task list.
auth_view=no
# Set this option to "yes" to require authentication to modify the task list.
auth_edit=no
# Clients which are allowed to access the sections requiring authentication
# above. This option can be set to either an IP address or a certificate SHA
# hash, as seen by the server, and can appear more than once. IP addresses
# have higher priority than certificate hashes. To find out the correct
# certificate hash, examine the server logs after navigating to the app's page
# with an unauthorized client.
allow=
'''
# Read persistent state
def read_state(filename):
for location in state_locations:
try:
state_file = location / filename
data = [x for x in state_file.read_text().split('\n') if x != '']
return data
except OSError:
continue
# Write persistent state
def write_state(filename, data, raw=False):
if not raw:
for i, line in enumerate(data):
if ' ' in line:
data[i] = urllib.parse.quote_plus(line)
for location in state_locations:
try:
location.mkdir(parents=True, exist_ok=True)
state_file = location / filename
state_file.write_text('\n'.join(data) + '\n')
return
except OSError:
continue
print('42 Unable to write state\r')
raise SystemExit
# Check authorization
def check_auth():
if remote_addr not in allowed_users:
if cert_hash == '':
print('60 Client certificate required\r')
raise SystemExit
elif cert_hash not in allowed_users:
sys.stderr.write('certificate hash: ' + cert_hash)
print('61 Certificate not authorized\r')
raise SystemExit
# Collect request parameters
server_name = os.environ['SERVER_NAME'] if 'SERVER_NAME' in os.environ else ''
path_info = os.environ['PATH_INFO'] if 'PATH_INFO' in os.environ else ''
query_string = os.environ['QUERY_STRING'].replace('/', '%2F') if 'QUERY_STRING' in os.environ else ''
remote_addr = os.environ['REMOTE_ADDR'] if 'REMOTE_ADDR' in os.environ else ''
# Workaround for https://github.com/michael-lazar/jetforce/issues/67
# This allows IPv4 addresses to be specified normally in the config file
if remote_addr.startswith('::ffff:'):
remote_addr = remote_addr[7:]
cert_hash = os.environ['TLS_CLIENT_HASH'].lower() if 'TLS_CLIENT_HASH' in os.environ else ''
# Find and read the configuration file
_data = read_state('todo.conf')
# If no config file is found, write out defaults
if _data is None:
write_state('todo.conf', [CONFIG_TEMPLATE], True)
_data = CONFIG_TEMPLATE.split('\n')
# Scan the config file for relevant options
auth_view = False
auth_edit = False
allowed_users = []
for line in _data:
if line.startswith('auth_view='):
auth_view = True if line[10:].lower() in ['yes', '1', 'true'] else False
elif line.startswith('auth_edit='):
auth_edit = True if line[10:].lower() in ['yes', '1', 'true'] else False
elif line.startswith('allow='):
_param = line[6:].lower()
if _param != '':
allowed_users.append(_param)
# List entries
if path_info == '':
if auth_view:
check_auth()
print('20 text/gemini\r')
print('=> /cgi-bin/todo/add + Add New')
print('=> /cgi-bin/todo/multiadd + Add Multiple')
print('____________________\r')
print()
for line in read_state('todo.list'):
if ' ' in line:
line = urllib.parse.quote_plus(line)
unquoted = urllib.parse.unquote_plus(line)
print('=> /cgi-bin/todo/rm?{} √ {}'.format(line, unquoted))
# Add new entry
elif path_info == '/add' or path_info == '/multiadd':
if auth_edit:
check_auth()
if query_string == '':
print('10 Enter new entry\r')
else:
state = read_state('todo.list')
# Split input at newlines and add each as separate entries
if '\n' in query_string or ' ' in query_string:
query_string = urllib.parse.quote_plus(query_string)
for line in query_string.split('%0A'):
line = line.strip('+')
if line != '' and line not in state:
state.append(line)
write_state('todo.list', state)
if path_info == '/multiadd':
print('30 gemini://{}/cgi-bin/todo/multiadd\r'.format(server_name))
print('30 gemini://{}/cgi-bin/todo\r'.format(server_name))
# Remove existing entry
elif path_info == '/rm':
if auth_edit:
check_auth()
if query_string == '':
print('59 Missing parameter\r')
else:
state = read_state('todo.list')
if query_string in state:
state.remove(query_string)
write_state('todo.list', state)
print('30 gemini://{}/cgi-bin/todo\r'.format(server_name))
# Deny all other requests
else:
print('50 Not found\r')