Simon Volpert gmitodo / master todo
master

Tree @master (Download .tar.gz)

todo @masterraw · history · blame

#!/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')