Rewrite, user authentication
82
bitvend.py
|
@ -1,80 +1,16 @@
|
|||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG) # noqa
|
||||
|
||||
import threading
|
||||
import urllib.parse
|
||||
import flask
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
import six
|
||||
|
||||
from bitvend.mdb import BitvendCashlessMDBDevice
|
||||
from bitvend.utils import to_local_currency, from_local_currency, format_btc, \
|
||||
sat_to_btc
|
||||
from bitvend.processor import PaymentProcessor
|
||||
from bitvend.models import db, Transaction
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
from prometheus_client import start_http_server
|
||||
start_http_server(8000)
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
app.config.from_object('bitvend.default_settings')
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
dev = BitvendCashlessMDBDevice()
|
||||
dev.init_app(app)
|
||||
|
||||
proc = PaymentProcessor(dev)
|
||||
proc.init_app(app)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return flask.render_template(
|
||||
'index.html', items=app.config['ITEMS'],
|
||||
mdb_online=dev.online,
|
||||
proc_online=proc.online)
|
||||
|
||||
@app.route('/log')
|
||||
def log():
|
||||
return flask.render_template(
|
||||
'log.html', transactions=Transaction.query.all())
|
||||
|
||||
@app.route('/reclaim/<tx_hash>')
|
||||
def reclaim(tx_hash):
|
||||
tx = Transaction.query.filter_by(tx_hash=tx_hash).first()
|
||||
|
||||
if tx and tx.product_id is None:
|
||||
dev.begin_session(tx.value, tx_hash)
|
||||
dev.begin_session(tx.value, tx_hash)
|
||||
dev.begin_session(tx.value, tx_hash)
|
||||
return flask.redirect('/log')
|
||||
|
||||
flask.abort(404)
|
||||
|
||||
@app.route('/qrcode/<path:data>')
|
||||
def qrcode_gen(data):
|
||||
bio = six.BytesIO()
|
||||
qr = qrcode.QRCode(border=0, box_size=50)
|
||||
qr.add_data(data)
|
||||
img = qr.make_image(image_factory=qrcode.image.svg.SvgPathFillImage)
|
||||
img.save(bio)
|
||||
return bio.getvalue(), 200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public,max-age=3600',
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
def ctx_utils():
|
||||
return {
|
||||
'from_local_currency': from_local_currency,
|
||||
'to_local_currency': to_local_currency,
|
||||
'format_btc': format_btc,
|
||||
'sat_to_btc': sat_to_btc,
|
||||
'qrcode': lambda data: flask.url_for('qrcode_gen', data=data),
|
||||
}
|
||||
from bitvend import create_app, dev, proc, db
|
||||
|
||||
if __name__ == "__main__":
|
||||
from prometheus_client import start_http_server
|
||||
start_http_server(8000)
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import flask
|
||||
|
||||
from bitvend.processor import PaymentProcessor
|
||||
from bitvend.mdb import BitvendCashlessMDBDevice
|
||||
|
||||
dev = BitvendCashlessMDBDevice()
|
||||
proc = PaymentProcessor(dev)
|
||||
|
||||
from bitvend.utils import to_local_currency, from_local_currency, format_btc, \
|
||||
sat_to_btc
|
||||
from bitvend.models import db, Transaction
|
||||
from bitvend.auth import login_manager
|
||||
|
||||
import bitvend.views
|
||||
|
||||
|
||||
def create_app():
|
||||
app = flask.Flask(__name__)
|
||||
app.config.from_object('bitvend.default_settings')
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
dev.init_app(app)
|
||||
proc.init_app(app)
|
||||
|
||||
app.register_blueprint(bitvend.views.bp)
|
||||
|
||||
@app.context_processor
|
||||
def ctx_utils():
|
||||
return {
|
||||
'from_local_currency': from_local_currency,
|
||||
'to_local_currency': to_local_currency,
|
||||
'format_btc': format_btc,
|
||||
'sat_to_btc': sat_to_btc,
|
||||
'qrcode': lambda data: flask.url_for('bitvend.qrcode_gen', data=data),
|
||||
'current_transaction': Transaction.query.filter(Transaction.finished == False).first(),
|
||||
}
|
||||
|
||||
return app
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from bitvend import create_app
|
||||
|
||||
app = create_app()
|
|
@ -0,0 +1,31 @@
|
|||
import requests
|
||||
import functools
|
||||
from flask import session, flash, redirect, request
|
||||
from flask_login import login_user, LoginManager, logout_user
|
||||
from bitvend.models import User, db
|
||||
|
||||
login_manager = LoginManager()
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.filter(User.uid == user_id).first()
|
||||
|
||||
def try_login(username, password):
|
||||
resp = requests.post('https://auth.hackerspace.pl/', data={
|
||||
'login': username,
|
||||
'password': password
|
||||
})
|
||||
|
||||
if resp.status_code == 200:
|
||||
u = User.query.get(username)
|
||||
|
||||
if not u:
|
||||
u = User(uid=username)
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
|
||||
login_user(u)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
|
@ -1,11 +1,16 @@
|
|||
import platform
|
||||
|
||||
SECRET_KEY = 'testing'
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///storage-%s.db' % (platform.node(),)
|
||||
|
||||
INPUT_ADDRESS = '12fkW5EBb3uBy1zD8pan4TcbabP5Fjato7'
|
||||
BLOCKCYPHER_CHAIN = 'btc/main'
|
||||
|
||||
#INPUT_ADDRESS ='n2SYFMbgfG4LXkvB4VgF4SeSEqQE28NAuV'
|
||||
#BLOCKCYPHER_CHAIN = 'btc/test3'
|
||||
|
||||
TEMPLATES_AUTO_RELOAD = True
|
||||
ITEMS = [
|
||||
{
|
||||
|
|
|
@ -8,14 +8,14 @@ from mdb.constants import *
|
|||
|
||||
|
||||
class BitvendCashlessMDBDevice(CashlessMDBDevice):
|
||||
current_tx_hash = None
|
||||
current_tx_id = None
|
||||
app = None
|
||||
|
||||
def init_app(self, app):
|
||||
self.app = app
|
||||
|
||||
def begin_session(self, amount, tx_hash=None):
|
||||
self.current_tx_hash = tx_hash
|
||||
def begin_session(self, amount, tx_id=None):
|
||||
self.current_tx_id = tx_id
|
||||
|
||||
super(BitvendCashlessMDBDevice, self).begin_session(amount)
|
||||
|
||||
|
@ -25,15 +25,21 @@ class BitvendCashlessMDBDevice(CashlessMDBDevice):
|
|||
self.send([0x05, 0x00, 0xff])
|
||||
self.current_request.processed = True
|
||||
|
||||
self.logger.info('got vend request: %r', self.current_tx_hash)
|
||||
self.logger.info('got vend request: %r', self.current_tx_id)
|
||||
|
||||
if self.current_tx_hash:
|
||||
if self.current_tx_id:
|
||||
with self.app.app_context():
|
||||
tx = Transaction.query.filter_by(tx_hash=self.current_tx_hash).first()
|
||||
tx = Transaction.query.get(self.current_tx_id)
|
||||
tx.product_id = product
|
||||
tx.product_value = value
|
||||
|
||||
if tx.amount is None:
|
||||
tx.amount = -value
|
||||
|
||||
db.session.commit()
|
||||
|
||||
self.current_tx_id = None
|
||||
|
||||
cashless_purchase_counter.inc()
|
||||
|
||||
return True
|
||||
|
|
|
@ -1,14 +1,91 @@
|
|||
from flask import current_app as app
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.sql import func, select
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class TransferException(Exception): pass
|
||||
class NoFunds(TransferException): pass
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
uid = db.Column(db.String(64), primary_key=True)
|
||||
transactions = db.relationship('Transaction', backref='user', lazy='dynamic')
|
||||
|
||||
def __str__(self):
|
||||
return self.uid
|
||||
|
||||
@hybrid_property
|
||||
def balance(self):
|
||||
return sum((_.amount or 0) for _ in self.transactions)
|
||||
|
||||
@balance.expression
|
||||
def balance(self):
|
||||
return (select([func.sum(Transaction.amount)]).
|
||||
where(Transaction.uid == cls.uid).
|
||||
label("balance")
|
||||
)
|
||||
|
||||
def transfer(self, target, amount):
|
||||
if self.balance - amount < -self.debt_limit:
|
||||
raise NoFunds()
|
||||
|
||||
self.transactions.append(Transaction(
|
||||
amount=-amount, type='transfer'
|
||||
))
|
||||
target.transactions.append(Transaction(
|
||||
amount=amount, type='transfer'
|
||||
))
|
||||
|
||||
@property
|
||||
def debt_limit(self):
|
||||
return app.config.get('DEBT_LIMIT', 5000)
|
||||
|
||||
@hybrid_property
|
||||
def amount_available(self):
|
||||
return self.balance + self.debt_limit
|
||||
|
||||
is_authenticated = True
|
||||
is_active = True
|
||||
is_anonymous = False
|
||||
|
||||
def get_id(self):
|
||||
return self.uid
|
||||
|
||||
@property
|
||||
def transaction_in_progress(self):
|
||||
return self.transactions.filter(Transaction.finished == False).count()
|
||||
|
||||
|
||||
class Transaction(db.Model):
|
||||
__tablename__ = 'transactions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tx_hash = db.Column(db.String)
|
||||
uid = db.Column(db.String(64), db.ForeignKey('users.uid'))
|
||||
|
||||
amount = db.Column(db.Integer)
|
||||
|
||||
type = db.Column(db.String(32), default='manual')
|
||||
|
||||
related = db.Column(db.String)
|
||||
related_user = db.relationship('User', foreign_keys=[related], primaryjoin=related==User.uid)
|
||||
|
||||
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
value = db.Column(db.Integer)
|
||||
#value = db.Column(db.Integer)
|
||||
@hybrid_property
|
||||
def value(self):
|
||||
return self.amount
|
||||
|
||||
product_id = db.Column(db.Integer)
|
||||
product_value = db.Column(db.Integer)
|
||||
|
||||
@hybrid_property
|
||||
def finished(self):
|
||||
return (self.type != 'purchase') | (self.product_id != None)
|
||||
|
|
|
@ -64,7 +64,9 @@ class PaymentProcessor(threading.Thread):
|
|||
|
||||
with self.app.app_context():
|
||||
tx = Transaction(tx_hash=tx_hash)
|
||||
tx.value = to_local_currency(tx_value)
|
||||
tx.uid = '__bitcoin__'
|
||||
tx.amount = -to_local_currency(tx_value)
|
||||
tx.type = 'purchase'
|
||||
db.session.add(tx)
|
||||
db.session.commit()
|
||||
|
||||
|
@ -79,9 +81,9 @@ class PaymentProcessor(threading.Thread):
|
|||
self.logger.info('Transaction ok, going to device...')
|
||||
|
||||
# FIXME we need better handling of ACK on POLL responses...
|
||||
self.device.begin_session(to_local_currency(tx_value), tx_hash)
|
||||
self.device.begin_session(to_local_currency(tx_value), tx_hash)
|
||||
self.device.begin_session(to_local_currency(tx_value), tx_hash)
|
||||
self.device.begin_session(to_local_currency(tx_value), tx.id)
|
||||
self.device.begin_session(to_local_currency(tx_value), tx.id)
|
||||
self.device.begin_session(to_local_currency(tx_value), tx.id)
|
||||
|
||||
def on_message(self, ws, message):
|
||||
#print message
|
||||
|
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 330 KiB |
Before Width: | Height: | Size: 369 KiB After Width: | Height: | Size: 369 KiB |
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 149 KiB |
|
@ -24,6 +24,9 @@ body {
|
|||
footer {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.well h3 { padding: 0; }
|
||||
.well h3 small { padding-top: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -50,16 +53,37 @@ body {
|
|||
<span class="label label-danger">payments: offline</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<ul class="nav navbar-right navbar-nav">
|
||||
<li><a href="{{ url_for('bitvend.logout') }}">Logout</a>
|
||||
</ul>
|
||||
<p class="navbar-text navbar-right">
|
||||
<small>Logged in as:</small> <b>{{ current_user }}</b>
|
||||
</p>
|
||||
{% else %}
|
||||
<ul class="nav navbar-right navbar-nav">
|
||||
<li><a href="{{ url_for('bitvend.login') }}">Login</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<p class="navbar-text navbar-right">
|
||||
1zł = {{ format_btc(from_local_currency(100)) }}
|
||||
</p>
|
||||
<p class="navbar-text navbar-right">
|
||||
<b>Rate:</b> {{ to_local_currency(100000000) / 100 }}zł
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{category}}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
<hr>
|
||||
<footer class="row"><big>made by <a href="https://wiki.hackerspace.pl/people:informatic:start">inf</a></big>
|
|
@ -1,10 +1,40 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
{% if not mdb_online or not proc_online %}
|
||||
{% if false and not mdb_online or not proc_online %}
|
||||
<div class="alert alert-warning">
|
||||
<b>Some of the subsystems are misbehaving.</b> Please avoid making payments, unless in absolute need of Mate.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="row">
|
||||
<div class="col-sm-4 col-sm-offset-2">
|
||||
<div class="well text-right">
|
||||
<h3><small class="pull-left">Balance</small> <small>{{ current_user.amount_available }} / </small> {{ current_user.balance }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="well text-right">
|
||||
<h3><small class="pull-left">Purchases</small> {{ current_user.transactions.count() }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-sm-offset-3">
|
||||
{% if current_transaction %}
|
||||
<a href="{{ url_for('.cancel') }}" class="btn btn-danger btn-block btn-lg">Cancel transaction
|
||||
{% if current_transaction.user != current_user %}
|
||||
<small>(by {{ current_transaction.user }})</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('.begin') }}" class="btn btn-primary btn-block btn-lg">Begin transaction</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
This is just a test deployment of Warsaw Hackerspace Vending Machine Bitcoin Payments System™.<br />
|
||||
<b>Please report any issues to <a href="mailto:informatic@hackerspace.pl" class="alert-link">informatic@hackerspace.pl</a>.</b>
|
|
@ -0,0 +1,16 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-md-offset-4 well">
|
||||
<form action="{{ url_for('.login_submit') }}" class="loginform" method="POST">
|
||||
{% if not logged_in %}
|
||||
<input type="text" name="username" placeholder="username" class="form-control input-lg" autofocus />
|
||||
<input type="password" name="password" placeholder="password" class="form-control input-lg" />
|
||||
{% endif %}
|
||||
|
||||
<button class="btn btn-lg btn-block">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,97 @@
|
|||
from flask import Blueprint, render_template, redirect, request, flash, url_for
|
||||
from flask import current_app as app
|
||||
import six
|
||||
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
|
||||
from bitvend import dev, proc
|
||||
from bitvend.models import db, Transaction
|
||||
from bitvend.auth import try_login
|
||||
from flask_login import login_required, current_user, logout_user
|
||||
|
||||
|
||||
bp = Blueprint('bitvend', __name__, template_folder='templates')
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
return render_template(
|
||||
'index.html', items=app.config['ITEMS'],
|
||||
mdb_online=dev.online,
|
||||
proc_online=proc.online)
|
||||
|
||||
@bp.route('/login')
|
||||
def login():
|
||||
return render_template('login.html')
|
||||
|
||||
@bp.route('/login', methods=['POST'])
|
||||
def login_submit():
|
||||
if try_login(request.form.get('username'), request.form.get('password')):
|
||||
flash('Login successful', 'success')
|
||||
return redirect('/')
|
||||
|
||||
flash('Login failed', 'danger')
|
||||
return redirect(url_for('.login'))
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@bp.route('/log')
|
||||
@login_required
|
||||
def log():
|
||||
return render_template(
|
||||
'log.html', transactions=Transaction.query.all())
|
||||
|
||||
@bp.route('/begin')
|
||||
@login_required
|
||||
def begin():
|
||||
if Transaction.query.filter(Transaction.finished == False).count():
|
||||
flash('Nope xD', 'danger')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
tx = Transaction(type='purchase')
|
||||
current_user.transactions.append(tx)
|
||||
db.session.commit()
|
||||
|
||||
dev.begin_session(current_user.amount_available, tx.id)
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@bp.route('/cancel')
|
||||
@login_required
|
||||
def cancel():
|
||||
dev.cancel_session()
|
||||
|
||||
# FIXME racey
|
||||
|
||||
Transaction.query.filter(Transaction.finished == False).delete()
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@bp.route('/reclaim/<tx_hash>')
|
||||
@login_required
|
||||
def reclaim(tx_hash):
|
||||
tx = Transaction.query.filter_by(tx_hash=tx_hash).first()
|
||||
|
||||
if tx and tx.product_id is None:
|
||||
dev.begin_session(tx.value, tx_hash)
|
||||
dev.begin_session(tx.value, tx_hash)
|
||||
dev.begin_session(tx.value, tx_hash)
|
||||
return redirect('/log')
|
||||
|
||||
abort(404)
|
||||
|
||||
@bp.route('/qrcode/<path:data>')
|
||||
def qrcode_gen(data):
|
||||
bio = six.BytesIO()
|
||||
qr = qrcode.QRCode(border=0, box_size=50)
|
||||
qr.add_data(data)
|
||||
img = qr.make_image(image_factory=qrcode.image.svg.SvgPathFillImage)
|
||||
img.save(bio)
|
||||
return bio.getvalue(), 200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public,max-age=3600',
|
||||
}
|
|
@ -74,3 +74,17 @@ class RaspiBackend(Backend):
|
|||
|
||||
self.pi.wave_delete(wid)
|
||||
self.pi.set_mode(self.tx_pin, pigpio.INPUT)
|
||||
|
||||
|
||||
#
|
||||
# Backend device based on STM32F1 MDB-USB adapter (to be actually designed...)
|
||||
#
|
||||
class SerialBackend(Backend):
|
||||
def __init__(self, device='/dev/ttyACM0'):
|
||||
self.ser = serial.Serial(device)
|
||||
|
||||
def read(self):
|
||||
return self.ser.read()
|
||||
|
||||
def write(self, data):
|
||||
self.ser.write(data)
|
||||
|
|