From 531072d2aad6660c5e58e70934dd7dee38607094 Mon Sep 17 00:00:00 2001 From: Sander Ferdinand Date: Sat, 20 Oct 2018 02:11:54 +0200 Subject: [PATCH] Include QR codes on the proposal page; added API route --- .gitignore | 1 + README.md | 8 ++- funding/api.py | 40 +++++++++-- funding/bin/qr.py | 86 ++++++++++++++++++++++++ funding/bin/utils.py | 21 +++++- funding/cache.py | 2 +- funding/orm/orm.py | 1 + funding/routes.py | 4 +- funding/static/css/wow.css | 5 ++ funding/templates/api.html | 16 ++++- funding/templates/proposal/proposal.html | 16 +++-- funding/validation.py | 1 + requirements.txt | 5 +- run_dev.py | 1 + settings.py_example | 1 + 15 files changed, 189 insertions(+), 19 deletions(-) create mode 100644 funding/bin/qr.py diff --git a/.gitignore b/.gitignore index 5ed321b..fe74043 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ settings.py .env htmlcov .coverage +funding/static/qr/* \ No newline at end of file diff --git a/README.md b/README.md index 68a7845..84662d2 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,14 @@ Expose wallet via RPC. Download application and configure. ``` -sudo apt install python-virtualenv python3 redis-server postgresql-server postgresql-server-dev-* +sudo apt install libjpeg-dev libpng-dev python-virtualenv python3 redis-server postgresql-server postgresql-server-dev-* git clone https://github.com/skftn/wownero-wfs.git -cd funding +cd wownero-wfs virtualenv -p /usr/bin/python3 source venv/bin/activate +pip uninstall pillow pip install -r requirements.txt +CC="cc -mavx2" pip install -U --force-reinstall pillow-simd cp settings.py_example settings.py - change settings accordingly ``` @@ -55,6 +57,8 @@ python run_dev.py Beware `run_dev.py` is meant as a development server. +When running behind nginx/apache, inject `X-Forwarded-For`. + ### Contributors - [camthegeek](https://github.com/camthegeek) diff --git a/funding/api.py b/funding/api.py index f0d5297..1d6e530 100644 --- a/funding/api.py +++ b/funding/api.py @@ -1,11 +1,13 @@ -from datetime import datetime -from flask import request, redirect, Response, abort, render_template, url_for, flash, make_response, send_from_directory, jsonify -from flask.ext.login import login_user , logout_user , current_user , login_required, current_user +from flask import jsonify, send_from_directory, Response, request from flask_yoloapi import endpoint, parameter + import settings +from funding.bin.utils import get_ip +from funding.bin.qr import QrCodeGenerator from funding.factory import app, db_session from funding.orm.orm import Proposal, User + @app.route('/api/1/proposals') @endpoint.api( parameter('status', type=int, location='args', default=1), @@ -21,6 +23,7 @@ def api_proposals_get(status, cat, limit, offset): return 'error', 500 return [p.json for p in proposals] + @app.route('/api/1/convert/wow-usd') @endpoint.api( parameter('amount', type=int, location='args', required=True) @@ -28,4 +31,33 @@ def api_proposals_get(status, cat, limit, offset): def api_coin_usd(amount): from funding.bin.utils import Summary, coin_to_usd prices = Summary.fetch_prices() - return jsonify(usd=coin_to_usd(amt=amount, btc_per_coin=prices['coin-btc'], usd_per_btc=prices['btc-usd'])) \ No newline at end of file + return jsonify(usd=coin_to_usd(amt=amount, btc_per_coin=prices['coin-btc'], usd_per_btc=prices['btc-usd'])) + + +@app.route('/api/1/qr') +@endpoint.api( + parameter('address', type=str, location='args', required=True) +) +def api_qr_generate(address): + """ + Generate a QR image. Subject to IP throttling. + :param address: valid receiving address + :return: + """ + from funding.factory import cache + + throttling_seconds = 3 + ip = get_ip() + cache_key = 'qr_ip_%s' % ip + hit = cache.get(cache_key) + if hit and ip not in ['127.0.0.1', 'localhost']: + return Response('Wait a bit before generating a new QR', 403) + cache.set(cache_key, {}, throttling_seconds) + + qr = QrCodeGenerator() + if not qr.exists(address): + created = qr.create(address) + if not created: + raise Exception('Could not create QR code') + + return send_from_directory('static/qr', '%s.png' % address) diff --git a/funding/bin/qr.py b/funding/bin/qr.py new file mode 100644 index 0000000..4d7a1f4 --- /dev/null +++ b/funding/bin/qr.py @@ -0,0 +1,86 @@ +import os +from io import BytesIO + +import pyqrcode +from PIL import Image, ImageDraw + +import settings + + +class QrCodeGenerator: + def __init__(self): + self.base = 'funding/static/qr' + self.image_size = (300, 300) + self.pil_save_options = { + 'quality': 25, + 'optimize': True + } + + if not os.path.exists(self.base): + os.mkdir(self.base) + + def exists(self, address): + if not os.path.exists(self.base): + os.mkdir(self.base) + if os.path.exists(os.path.join(self.base, '%s.png' % address)): + return True + + def create(self, address, dest=None, color_from=(210, 83, 200), color_to=(255, 169, 62)): + """ + Create QR code image. Optionally a gradient. + :param address: + :param dest: + :param color_from: gradient from color + :param color_to: gradient to color + :return: + """ + if len(address) != settings.COIN_ADDRESS_LENGTH: + raise Exception('faulty address length') + + if not dest: + dest = os.path.join(self.base, '%s.png' % address) + + created = pyqrcode.create(address, error='L') + buffer = BytesIO() + created.png(buffer, scale=14, quiet_zone=2) + + im = Image.open(buffer) + im = im.convert("RGBA") + im.thumbnail(self.image_size) + + im_data = im.getdata() + + # make black color transparent + im_transparent = [] + for color_point in im_data: + if sum(color_point[:3]) == 255 * 3: + im_transparent.append(color_point) + else: + # get rid of the subtle grey borders + alpha = 0 if color_from and color_to else 1 + im_transparent.append((0, 0, 0, alpha)) + continue + + if not color_from and not color_to: + im.save(dest, **self.pil_save_options) + return dest + + # turn QR into a gradient + im.putdata(im_transparent) + + gradient = Image.new('RGBA', im.size, color=0) + draw = ImageDraw.Draw(gradient) + + for i, color in enumerate(QrCodeGenerator.gradient_interpolate(color_from, color_to, im.width * 2)): + draw.line([(i, 0), (0, i)], tuple(color), width=1) + + im_gradient = Image.alpha_composite(gradient, im) + im_gradient.save(dest, **self.pil_save_options) + + return dest + + @staticmethod + def gradient_interpolate(color_from, color_to, interval): + det_co = [(t - f) / interval for f, t in zip(color_from, color_to)] + for i in range(interval): + yield [round(f + det * i) for f, det in zip(color_from, det_co)] diff --git a/funding/bin/utils.py b/funding/bin/utils.py index 3663cf7..3ba8672 100644 --- a/funding/bin/utils.py +++ b/funding/bin/utils.py @@ -1,15 +1,21 @@ +import os +import json from datetime import datetime, date + +import pyqrcode import requests -from flask import g +from flask import g, request from flask.json import JSONEncoder -import json + import settings + def json_encoder(obj): if isinstance(obj, (datetime, date)): return obj.isoformat() raise TypeError ("Type %s not serializable" % type(obj)) + class Summary: @staticmethod def fetch_prices(): @@ -60,26 +66,35 @@ class Summary: cache.set(cache_key, data=data, expiry=300) return data + def price_cmc_btc_usd(): headers = {'User-Agent': 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0'} try: + print('request coinmarketcap') r = requests.get('https://api.coinmarketcap.com/v2/ticker/1/?convert=USD', headers=headers) r.raise_for_status() return r.json().get('data', {}).get('quotes', {}).get('USD', {}).get('price') except: return + def coin_btc_value(): headers = {'User-Agent': 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0'} try: + print('request TO') r = requests.get('https://tradeogre.com/api/v1/ticker/BTC-WOW', headers=headers) r.raise_for_status() return float(r.json().get('high')) except: return + def coin_to_usd(amt: float, usd_per_btc: float, btc_per_coin: float): try: return round(usd_per_btc / (1.0 / (amt * btc_per_coin)), 2) except: - pass \ No newline at end of file + pass + + +def get_ip(): + return request.headers.get('X-Forwarded-For') or request.remote_addr diff --git a/funding/cache.py b/funding/cache.py index 301057c..1878a9e 100644 --- a/funding/cache.py +++ b/funding/cache.py @@ -58,4 +58,4 @@ class WowCache: return {} def set(self, key: str, data: dict, expiry = 300): - self._cache.set(key, json.dumps(data, default=json_encoder), ex=expiry) \ No newline at end of file + self._cache.set(key, json.dumps(data, default=json_encoder), ex=expiry) diff --git a/funding/orm/orm.py b/funding/orm/orm.py index f3c48ce..6ccc5a7 100644 --- a/funding/orm/orm.py +++ b/funding/orm/orm.py @@ -351,6 +351,7 @@ class Payout(base): @staticmethod def get_payouts(proposal_id): + from funding.factory import db_session return db_session.query(Payout).filter(Payout.proposal_id == proposal_id).all() diff --git a/funding/routes.py b/funding/routes.py index 27a6126..985c681 100644 --- a/funding/routes.py +++ b/funding/routes.py @@ -154,7 +154,7 @@ def proposal_api_add(title, content, pid, funds_target, addr_receiving, category return make_response(jsonify('letters detected'),500) if funds_target < 1: return make_response(jsonify('Proposal asking less than 1 error :)'), 500) - if len(addr_receiving) != 97: + if len(addr_receiving) != settings.COIN_ADDRESS_LENGTH: return make_response(jsonify('Faulty address, should be of length 72'), 500) p = Proposal(headline=title, content=content, category='misc', user=current_user) @@ -208,6 +208,7 @@ def user(name): user = q.first() return render_template('user.html', user=user) + @app.route('/proposals') @endpoint.api( parameter('status', type=int, location='args', required=False), @@ -234,6 +235,7 @@ def proposals(status, page, cat): return make_response(render_template('proposal/proposals.html', proposals=proposals, status=status, cat=cat)) + @app.route('/register', methods=['GET', 'POST']) def register(): if settings.USER_REG_DISABLED: diff --git a/funding/static/css/wow.css b/funding/static/css/wow.css index 0d3c59d..b1944a6 100644 --- a/funding/static/css/wow.css +++ b/funding/static/css/wow.css @@ -608,3 +608,8 @@ ul.b { .wow_addy[data-active="true"]{ cursor: default; } + +.proposal_qr{ + margin-top:8px; + margin-bottom:8px; +} \ No newline at end of file diff --git a/funding/templates/api.html b/funding/templates/api.html index 8754db1..1514a7b 100644 --- a/funding/templates/api.html +++ b/funding/templates/api.html @@ -66,12 +66,24 @@ ] } + +
+
GET /api/1/qr
+
+

+ WOW address to QR code generation. Returns 300x300 png. +

+ + Example: +
+ + link + +
{% include 'sidebar.html' %} - - {% endblock %} diff --git a/funding/templates/proposal/proposal.html b/funding/templates/proposal/proposal.html index 3a045da..61649ab 100644 --- a/funding/templates/proposal/proposal.html +++ b/funding/templates/proposal/proposal.html @@ -95,20 +95,20 @@
- {{proposal.balance['available']|round(3) or 0 }} WOW Raised - {% if (proposal.funds_target-proposal.balance['available']|float|round(3)) > 0 %} + {{proposal.balance['available']|round(3) or 0 }} WOW Raised + {% if (proposal.funds_target-proposal.balance['available']|float|round(3)) > 0 %} ({{ (proposal.funds_target-proposal.balance['available']|float|round(3)|int) }} WOW until goal) - {% else %} + {% else %} ({{ (proposal.balance['available']-proposal.funds_target|float|round(3)|int) }} WOW past goal!) {% endif %}
-
+


- +
{{proposal.spends['spent']|round(3) or 0}} WOW Paid out @@ -134,6 +134,12 @@ Donation address:
{% if proposal.addr_donation %}{{ proposal.addr_donation }}{% else %}None generated yet{% endif %}
+ + {% if proposal.addr_donation %} +
+ +
+ {% endif %} diff --git a/funding/validation.py b/funding/validation.py index 82b485a..f131028 100644 --- a/funding/validation.py +++ b/funding/validation.py @@ -2,6 +2,7 @@ def val_username(value): if len(value) >= 20: raise Exception("username too long") + def val_email(value): if len(value) >= 50: raise Exception("email too long") diff --git a/requirements.txt b/requirements.txt index c71d12b..f4c3d2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,7 @@ redis gunicorn psycopg2 markdown2 -requests \ No newline at end of file +requests +pyqrcode +pypng +pillow-simd diff --git a/run_dev.py b/run_dev.py index ce61c7a..76a4060 100644 --- a/run_dev.py +++ b/run_dev.py @@ -1,6 +1,7 @@ from funding.factory import create_app import settings + if __name__ == '__main__': app = create_app() app.run(host=settings.BIND_HOST, port=settings.BIND_PORT, diff --git a/settings.py_example b/settings.py_example index ee96463..37c6c2d 100644 --- a/settings.py_example +++ b/settings.py_example @@ -7,6 +7,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) SECRET = '' DEBUG = True +COIN_ADDRESS_LENGTH = 97 COINCODE = '' PSQL_USER = '' PSQL_PASS = ''