Simon Volpert httpay / master httpay
master

Tree @master (Download .tar.gz)

httpay @masterraw · history · blame

#!/usr/bin/env python
# A payment processing proxy for your files
# Author: Simon Volpert <simon@simonvolpert.com>
# License: AGPLv3 (see LICENSE for details)
# Project page: https://simonvolpert.com/httpay/

import sys
from pathlib import Path
from wsgiref.simple_server import make_server
import urllib.request
import string
import uuid
import mimetypes
import hashlib
import logging
import tempfile
import datetime
import json
import sqlite3
import signal
import base64
import qrcode
import io
import coinaddress


default_timeout = 10
listening_port = 8000
default_amount = 100000
global_unlock = False
api_url = 'https://bch.fullstack.cash/v6/fulcrum/balance/{address}'
api_key_balance = 'balance.confirmed'
api_key_unconfirmed = 'balance.unconfirmed'
api_unit = 'satoshi'

request_template = '''<div id="payment-request" style="text-align: center">
<p>Unlocking <strong>${filename}</strong>.</p>
<p>Please send at least ${amount} BCH to ${address}.</p>
<div id="qrcode-container" style="height: fit-content; width: fit-content; margin: auto">
<img id="qrcode" src="data:image/png;base64,${qr}" alt="Payment request QR code" title="${qr_data}"">
</div>
<p><a href="${qr_data}">Open this payment request in your wallet.</a></p>
<noscript><p>After sending the coins, please refresh the page to begin your download.</p></noscript>
<p>Your file will be available for you as long as the cookie in your browser is valid.</p>
<hr>
<p><small>This page was generated with the <a href="https://simonvolpert.com/httpay/">HTTPay payment processing proxy</a>.</small></p>
</div>
<script type="text/javascript">
        window.setTimeout(
                function(){
                        window.location.reload(1);
                }, 10000);
</script>'''

page_template = '''<html><body>
PAYMENT_REQUEST_BODY
</body></html>'''

# Utility function: check whether or not a file is unlocked for download
def check_unlock(filename, auth_cookie):
	try:
		result = state.execute('SELECT paid FROM requests WHERE (filename=? OR filename="*") AND cookie=?;', (filename, auth_cookie))
		paid, = result.fetchone()
	except TypeError:
		# An equivalent request does not exist
		return False
	return bool(paid)


# Simple wrapper around the somewhat convoluted hashlib method
def sha256hash(payload):
	h = hashlib.new('sha256')
	h.update(payload.encode())
	return h.hexdigest()


# Utility function: get the value at the end of a dot-separated key path of a JSON object
def get_json_value(json_object, key_path):
	for k in key_path.split('.'):
		# Process integer indices
		try:
			k = int(k)
		except ValueError:
			pass
		# Expand the key
		json_object = json_object[k]
	return json_object


# Get or generate a receiving address for use in invoices
def get_receiving_address():
	now = datetime.datetime.now()
	idx_file = data_dir / 'index.txt'
	idx_file.touch()
	try:
		idx = int(idx_file.read_text())
	except ValueError:
		idx = 0
	state.execute('SELECT filename, address, cookie, timestamp FROM requests WHERE paid=0 AND timestamp < datetime("now", "-1 hour");')
	try:
		filename, address, cookie, timestamp = state.fetchone()
	except TypeError:
		# No stale requests which can be reused, generate a new address
		address = coinaddress.address_from_xpub(network='bitcoin_cash', xpub=xpub, path=f'0/{idx}').split(':')[1]
		logging.debug(f'Generated new address {address} from derivation index {idx}.')
		idx_file.write_text(str(idx + 1))
		return address
	# Drop the stale request and reuse the address
	state.execute('DELETE FROM requests WHERE filename=? AND address=? AND cookie=?;', (filename, address, cookie))
	state_db.commit()
	logging.debug(f'Recycling address {address} from stale payment request from {cookie} for {filename} at {timestamp}')
	return address


# Utility function: get the balance of a receiving address
def get_balance(address):
	try:
		request = urllib.request.Request(api_url.replace('{address}', address), headers={})
		with urllib.request.urlopen(request, timeout=default_timeout) as webpage:
			data = json.loads(str(webpage.read(), 'UTF-8'))
		unconfirmed = float(get_json_value(data, api_key_unconfirmed.replace('{address}', address))) if api_key_unconfirmed != '' else 0
		balance = float(get_json_value(data, api_key_balance.replace('{address}', address))) + unconfirmed
		if api_unit == 'native':
			balance /= 100000000
		return balance
	except BaseException as exc:
		logging.error(f'Error getting address balance: {exc}')
		return 0


# Main webapp function
def httpay(environ, start_response):
	headers = [('Content-type', 'text/html; charset=UTF-8')]
	http_status = '200 OK'
	# Process request environment
	request_type = environ['REQUEST_METHOD'] if 'REQUEST_METHOD' in environ else 'GET'
	ip_addr = environ['HTTP_X_REAL_IP'] if 'HTTP_X_REAL_IP' in environ else environ['REMOTE_ADDR']
	filename = environ['PATH_INFO'].lstrip('/').split('/')[-1] if 'PATH_INFO' in environ else ''
	query_string = environ['QUERY_STRING'] if 'QUERY_STRING' in environ else ''
	http_cookie = environ['HTTP_COOKIE'] if 'HTTP_COOKIE' in environ else ''
	auth_cookie = ''
	# Don't trust the client to provide a safe and sane cookie, hash it instead
	for _c in http_cookie.split(';'):
		_c = _c.strip(' ')
		if _c.startswith('httpay-auth=') and _c.split('=')[1] != '':
			auth_cookie = sha256hash(_c)
	# Empty requests and unsupported methods are not allowed
	if filename == '' or request_type not in ['GET', 'HEAD']:
		http_status = '400 Bad Request'
		page = b'400 Bad Request'
		start_response(http_status, headers)
		return [page]
	# Check if the file exists in the data directory
	found = False
	for directory in ['assets', 'files']:
		file_path = data_dir / directory / filename
		if file_path.is_file():
			found = True
			break
	if not found:
		http_status = '404 Not Found'
		page = b'404 Not Found'
		start_response(http_status, headers)
		return [page]
	# Set a cookie if missing from this client
	if auth_cookie == '':
		_c = uuid.uuid4().hex
		_c = 'httpay-auth=' + _c
		headers.append(('Set-Cookie', _c))
		auth_cookie = sha256hash(_c)
		logging.debug(f'Assigned new auth cookie {auth_cookie} to connection from {ip_addr}')
	# Pass through assets and already unlocked files
	if directory == 'assets' or check_unlock(filename, auth_cookie):
		logging.info(f'Passing through {filename} for {auth_cookie} ({ip_addr})')
		mimetype = mimetypes.guess_type(filename)[0]
		headers = [('Content-Type', mimetype)]
		start_response(http_status, headers)
		return [file_path.read_bytes()]
	# Check if the filename already has an associated payment request
	now = datetime.datetime.now()
	timestamp = now.isoformat(' ', timespec='seconds')
	try:
		state.execute('SELECT address, timestamp FROM requests WHERE filename=? AND cookie=?;', (filename, auth_cookie))
		address, timestamp = state.fetchone()
		balance = get_balance(address)
		if api_unit == 'satoshi':
			balance = int(balance)
		# Unlock and pass through file if already paid
		if balance >= default_amount:
			state.execute('UPDATE requests SET paid=? WHERE filename=? AND cookie=?;', (True, '*' if global_unlock else filename, auth_cookie))
			state_db.commit()
			logging.info(f'Unlocking {filename} for {auth_cookie} ({ip_addr}) on payment of {balance}')
			mimetype = mimetypes.guess_type(filename)[0]
			headers = [('Content-Type', mimetype)]
			start_response(http_status, headers)
			return [file_path.read_bytes()]
		# Refresh timestamp for long-running payment requests
		state.execute('UPDATE requests SET timestamp=? WHERE filename=? AND cookie=?;', (timestamp, filename, auth_cookie))
		state_db.commit()
	except TypeError:
		# Create a new payment request
		address = get_receiving_address()
		state.execute('INSERT INTO requests VALUES(?, ?, ?, ?, ?);', (filename, auth_cookie, timestamp, address, False))
		state_db.commit()
		logging.info(f'New request for {filename} from {auth_cookie} ({ip_addr}) to {address}')
	# Show a payment request page
	http_status = '402 Payment Required'
	qr_data = f'bitcoincash:{address}?amount={amount}&message={filename}'
	qr_image = qrcode.make(qr_data, box_size=7, error_correction=qrcode.constants.ERROR_CORRECT_L)
	output = io.BytesIO()
	qr_image.save(output, format='PNG')
	qr = base64.b64encode(output.getvalue()).decode('UTF-8')
	payment_request = string.Template(request_template)
	try:
		template = (data_dir / 'template.html').read_text().split('\n')
	except (IOError, OSError):
		template = page_template.split('\n')
	# Patch the payment request template
	for num, line in enumerate(template):
		if line == 'PAYMENT_REQUEST_BODY':
			template[num] = payment_request.safe_substitute(address=address, filename=filename, amount=amount, qr_data=qr_data, qr=qr)
			break
	page = '\n'.join(template)
	# Serve the page or the file
	start_response(http_status, headers)
	return [bytes(page, 'UTF-8')]


# Set up logging
logger = logging.getLogger()
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.DEBUG)
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
logger.addHandler(handler)
logger.setLevel(logging.INFO)

# Look for the directory containing the configuration and state files
data_dir_locations = [
	Path.home() / '.httpay',
	Path.home() / '.config' / 'httpay',
	Path(__file__).parent,
	Path.cwd(),
	Path(tempfile.gettempdir())
]
if len(sys.argv) > 1:
	data_dir_locations.insert(0, Path(sys.argv[1]))
found = False
for data_dir in data_dir_locations:
	if data_dir.is_dir():
		conf_file = data_dir / 'httpay.cfg'
		if conf_file.is_file():
			found = True
			break
if not found:
	logging.error('The configuration file was not found in any of the search paths.\nPlease consult the README for installation and configuration instructions.')
	sys.exit(1)
logging.info(f'Using {data_dir} as data directory.')

# Initialize asset and file directories
for directory in ['assets', 'files']:
	state_dir = data_dir / directory
	if not state_dir.is_dir():
		# TODO: work around missing permissions
		state_dir.mkdir()
		logging.info(f'Created "{directory}" directory')

# Initialize state DB
state_db = sqlite3.connect(data_dir / "state.db")
state = state_db.cursor()
state.execute('CREATE TABLE IF NOT EXISTS requests(filename TEXT, cookie TEXT, timestamp TEXT, address TEXT, paid INTEGER);')

xpub = ''
# Scan the configuration file for parameters we care about
for line in conf_file.read_text().split('\n'):
	if line.startswith('xpub='):
		xpub = line.split('=')[1]
		# Validate the provided xPub
		try:
		    coinaddress.address_from_xpub(network='bitcoin_cash', xpub=xpub, path='0/0')
		except ValueError:
		    logging.error(f'Bad configuration value for xpub: "{xpub}". Please check your configuration and try again.')
		    sys.exit(2)
	elif line.startswith('default_price='):
		# Validate the amount
		_price = line.split('=')[1]
		try:
			_price = int(_price)
			if _price < 1 or _price > 2099995000:
				raise ValueError
		except ValueError:
			logging.warning(f'Bad configuration value for default_price: "{_price}". Using default value of {default_amount}.')
			_price = default_amount
		amount = _price / 100000000.0
	elif line.startswith('listening_port='):
		# Validate the listening port
		_port = line.split('=')[1]
		try:
			_port = int(_port)
			if _port < 1 or _port > 65535:
				raise ValueError
			listening_port = _port
		except ValueError:
			logging.warning(f'Bad configuration value for listening_port: "{_port}". Using default value of {listening_port}.')
	# Global/blanket unlocking on any payment-request
	elif line.startswith('global_unlock='):
			if line.endswith('yes'):
				global_unlock = True
				logging.info('Operating in "global unlock" mode.')
	# API settings
	elif line.startswith('api_url='):
		api_url = line.split['='][1]
	elif line.startswith('api_key_balance='):
		api_key_balance = line.split['='][1]
	elif line.startswith('api_key_unconfirmed='):
		api_key_unconfirmed = line.split('=')[1]
	elif line.startswith('api_unit='):
		_api_unit = line.split('=')[1]
		if _api_unit in ['native', 'satoshi']:
			api_unit = _api_unit
		else:
			logging.warning(f'Bad configuration value for api_unit: "{_api_unit}". Using default value of {api_unit}.')

# Register an exit signal handler
def clean_exit(signum, frame):
	logging.info('Server stopped.')
	state_db.close()
	sys.exit()

# Start the web server
if __name__ == "__main__":
	signal.signal(signal.SIGTERM, clean_exit)
	signal.signal(signal.SIGINT, clean_exit)
	try:
		signal.signal(signal.SIGHUP, clean_exit)
	except AttributeError:
		# Not a UNIX
		pass
	httpd = make_server('', listening_port, httpay)
	logging.info(f'Serving httpay on port {listening_port}...')
	httpd.serve_forever()