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