diff options
author | MS <ms@taler.net> | 2020-07-22 14:53:45 +0200 |
---|---|---|
committer | MS <ms@taler.net> | 2020-07-22 14:53:45 +0200 |
commit | 2d97ecc2c1ac605ca49e8a866b309daaeb7a831c (patch) | |
tree | 173f7917c5d0af822d2d51ed491c3cf2d8eaf23f /talermerchantdemos/blog/blog.py | |
download | taler-merchant-demos-2d97ecc2c1ac605ca49e8a866b309daaeb7a831c.tar.gz taler-merchant-demos-2d97ecc2c1ac605ca49e8a866b309daaeb7a831c.tar.bz2 taler-merchant-demos-2d97ecc2c1ac605ca49e8a866b309daaeb7a831c.zip |
Installing the Blog
Diffstat (limited to 'talermerchantdemos/blog/blog.py')
-rw-r--r-- | talermerchantdemos/blog/blog.py | 414 |
1 files changed, 414 insertions, 0 deletions
diff --git a/talermerchantdemos/blog/blog.py b/talermerchantdemos/blog/blog.py new file mode 100644 index 0000000..51a1596 --- /dev/null +++ b/talermerchantdemos/blog/blog.py @@ -0,0 +1,414 @@ +## +# This file is part of GNU TALER. +# Copyright (C) 2014-2017 INRIA +# +# TALER is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free Software +# Foundation; either version 2.1, or (at your option) any later version. +# +# TALER is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along with +# GNU TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +# +# @author Florian Dold +# @author Marcello Stanisci +# @brief Implementation of a Taler-compatible blog. + +import urllib.parse +from urllib.parse import urljoin, quote, urlencode +import logging +import os +import traceback +import uuid +import qrcode +import qrcode.image.svg +import base64 +import requests +import flask +import lxml.etree +import time +from cachelib import UWSGICache, SimpleCache +from taler.util.talerconfig import TalerConfig +from ..blog.content import ARTICLES, get_article_file, get_image_file + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +app = flask.Flask(__name__, template_folder=BASE_DIR) +app.secret_key = base64.b64encode(os.urandom(64)).decode('utf-8') + +LOGGER = logging.getLogger(__name__) +TC = TalerConfig.from_env() +BACKEND_BASE_URL = TC["frontends"]["backend"].value_string(required=True) +CURRENCY = TC["taler"]["currency"].value_string(required=True) +APIKEY = TC["frontends"]["backend_apikey"].value_string(required=True) +INSTANCE = "blog" +ARTICLE_AMOUNT = CURRENCY + ":0.5" +BACKEND_URL = urljoin(BACKEND_BASE_URL, f"instances/{INSTANCE}/") + +app.config.from_object(__name__) + + +## +# Extends the templating language with a function (@c env) +# that fetches values from the environment. +# +# @return a @a dict containing the extension. +@app.context_processor +def utility_processor(): + # These helpers will be available in templates + def env(name, default=None): + return os.environ.get(name, default) + + return dict(env=env) + + +## +# Return a error response to the client. +# +# @param abort_status_code status code to return along the response. +# @param params _kw_ arguments to passed verbatim to the templating engine. +def err_abort(abort_status_code, **params): + t = flask.render_template("templates/error.html", **params) + flask.abort(flask.make_response(t, abort_status_code)) + + +## +# Issue a GET request to the backend. +# +# @param endpoint the backend endpoint where to issue the request. +# @param params (dict type of) URL parameters to append to the request. +# @return the JSON response from the backend, or a error response +# if something unexpected happens. +def backend_get(endpoint, params): + headers = {"Authorization": "ApiKey " + APIKEY} + try: + resp = requests.get( + urljoin(BACKEND_URL, endpoint), params=params, headers=headers + ) + except requests.ConnectionError: + err_abort(500, message="Could not establish connection to backend") + try: + response_json = resp.json() + except ValueError: + err_abort(500, message="Could not parse response from backend") + if resp.status_code != 200: + err_abort( + 500, + message="Backend returned error status", + json=response_json, + status_code=resp.status_code + ) + return response_json + + +## +# POST a request to the backend, and return a error +# response if any error occurs. +# +# @param endpoint the backend endpoint where to POST +# this request. +# @param json the POST's body. +# @return the backend response (JSON format). +def backend_post(endpoint, json): + headers = {"Authorization": "ApiKey " + APIKEY} + try: + resp = requests.post( + urljoin(BACKEND_URL, endpoint), json=json, headers=headers + ) + except requests.ConnectionError: + err_abort(500, message="Could not establish connection to backend") + try: + response_json = resp.json() + except ValueError: + err_abort( + 500, + message="Could not parse response from backend", + status_code=resp.status_code + ) + if resp.status_code != 200: + err_abort( + 500, + message="Backend returned error status", + json=response_json, + status_code=resp.status_code + ) + return response_json + + +## +# "Fallback" exception handler to capture all the unmanaged errors. +# +# @param e the Exception object, currently unused. +# @return flask-native response object carrying the error message +# (and execution stack!). +@app.errorhandler(Exception) +def internal_error(e): + return flask.render_template( + "templates/error.html", + message="Internal error", + stack=traceback.format_exc() + ) + + +## +# Serve the main index page. +# +# @return response object of the index page. +@app.route("/") +def index(): + return flask.render_template( + "templates/index.html", + merchant_currency=CURRENCY, + articles=ARTICLES.values() + ) + + +## +# @brief Cache for paid articles (in the form <session_id>-<article_name>), +# so we don't always have to ask the backend / DB, and so we don't +# have to store variable-size cookies on the client. +try: + import uwsgi + paid_articles_cache = UWSGICache(0, "paid_articles") +except ImportError: + paid_articles_cache = SimpleCache() + + +@app.route("/confirm-refund/<order_id>", methods=["GET"]) +def confirm_refund(order_id): + # Here we don't care about the session ID + pay_params = dict(order_id=order_id) + pay_status = backend_get("check-payment", pay_params) + if not pay_status.get("paid"): + err_abort( + 400, + message="can't refund unpaid article", + ) + article_name = pay_status["contract_terms"]["extra"]["article_name"] + return flask.render_template( + "templates/confirm_refund.html", + article_name=article_name, + order_id=order_id) + + +## +# Triggers the refund by serving /refund/test?order_id=XY. +# Will be triggered by a "refund button". +# +# @param order_id the order ID of the transaction to refund. +# @return the following errors (named by HTTP response code): +# - 400: no article was asked to be refunded! +# - 401: the refund was asked on a non-payed article. +# - 500: the backend was unable to give response. +# Or, in the successful case, a redirection to the +# "refund URL" is returned; then the wallet will run +# the refund protocol in a transparent way. +@app.route("/refund/<order_id>", methods=["POST"]) +def refund(order_id): + if not order_id: + return flask.jsonify( + dict(error="Aborting refund: article not payed") + ), 401 + session_id = flask.session.get("session_id", "") + pay_params = dict(order_id=order_id, session_id=session_id) + pay_status = backend_get("check-payment", pay_params) + if not pay_status.get("paid"): + err_abort( + 402, + message="You did not pay for this article (nice try!)", + json=pay_status + ) + article_name = pay_status["contract_terms"]["extra"]["article_name"] + refund_spec = dict( + order_id=order_id, + reason="Demo reimbursement", + refund=ARTICLE_AMOUNT + ) + resp = backend_post("refund", refund_spec) + try: + # delete from paid article cache + paid_articles_cache.delete(session_id + "-" + article_name) + taler_refund_uri = resp["taler_refund_uri"] + qrcode_svg = get_qrcode_svg(taler_refund_uri) + content = flask.render_template( + "templates/show_refund.html", + article_name=article_name, + taler_refund_uri=taler_refund_uri, + qrcode_svg=qrcode_svg, + ) + headers = {"Taler": taler_refund_uri} + return flask.Response(content, status=402, headers=headers) + except KeyError: + err_abort( + 500, + message="Response from backend incomplete", + json=resp, + stack=traceback.format_exc() + ) + + +## +# Render the article after a successful purchase. +# +# @param article_name _slugged_ (= spaces converted to underscores) article title. +# @param data image filename to return along the article. +# @param order_id the ID of the order where this article got purchased. +# (Will be put in the refund-request form action, since any article +# will also have a "refund button" aside.) +# @return the following errors (named by HTTP return code): +# - 500: file for article not found. +# - 404: supplemental @a data not found. +# In the successful case, a response object carrying the +# article in it will be returned. +def render_article(article_name, data, order_id): + article_info = ARTICLES.get(article_name) + if article_info is None: + m = "Internal error: Files for article ({}) not found.".format( + article_name + ) + err_abort(500, message=m) + if data is not None: + if data in article_info.extra_files: + return flask.send_file(get_image_file(data)) + m = "Supplemental file ({}) for article ({}) not found.".format( + data, article_name + ) + err_abort(404, message=m) + # the order_id is needed for refunds + return flask.render_template( + "templates/article_frame.html", + article_file=get_article_file(article_info), + article_name=article_name, + order_id=order_id + ) + + +def get_qrcode_svg(data): + factory = qrcode.image.svg.SvgImage + img = qrcode.make(data, image_factory=factory) + return lxml.etree.tostring(img.get_image()).decode("utf-8") + + +## +# This endpoint is used by the payment request page +# to check if the payment has been completed via the QR code. +@app.route("/check-status/<order_id>/<session_id>") +def check_status(order_id, session_id): + pay_params = dict(order_id=order_id, session_id=session_id) + pay_status = backend_get("check-payment", pay_params) + return flask.jsonify(paid=pay_status["paid"]) + + +## +# Trigger a article purchase. The logic follows the main steps: +# +# 1. Always check if the article was paid already, via the +# "/check-payment" API from the backend. +# 2. If so, return the article. +# 3. If not, redirect the browser to a page where the +# wallet will initiate the payment protocol. +# +# @param article_name (slugged) article title. +# @param data filename of a supplement data (image/sound/..) +# @return the following errors might occur (named by HTTP response code): +# - 402: @a article_name does not correspond to the @a order_id +# of a PAYED article. +# - 500: neither the article was paid, nor a payment was triggered. +# - 400: a invalid order_id was passed along the GET parameters. +# In the successful case, either the article is returned, or +# the browser gets redirected to a page where the wallet can +# send the payment. +@app.route("/essay/<article_name>") +@app.route("/essay/<article_name>/data/<data>") +def article(article_name, data=None): + # We use an explicit session ID so that each payment (or payment replay) is + # bound to a browser. This forces re-play and prevents sharing the article + # by just sharing the URL. + session_id = flask.session.get("session_id") + order_id = flask.request.args.get("order_id") + + if not session_id: + session_id = flask.session["session_id"] = str(uuid.uuid4()) + + cached_order_id = paid_articles_cache.get(session_id + "-" + article_name) + if cached_order_id: + return render_article(article_name, data, cached_order_id) + + ## + # First-timer; generate order first. + if not order_id: + order = dict( + amount=ARTICLE_AMOUNT, + extra=dict(article_name=article_name), + fulfillment_url=flask.request.base_url, + summary="Essay: " + article_name.replace("_", " "), + # 10 minutes time for a refund + refund_deadline=dict(t_ms=1000*int(time.time() + 10 * 30)), + wire_transfer_deadline=dict(t_ms=1000*int(time.time() + 15 * 30)), + ) + order_resp = backend_post("private/orders", dict(order=order)) + order_id = order_resp["order_id"] + return flask.redirect( + flask.url_for( + "article", article_name=article_name, order_id=order_id + ) + ) + # + # Prepare data for the upcoming payment check. + # + pay_params = dict(order_id=order_id, session_id=session_id) + + pay_status = backend_get("private/orders/{}".format(order_id), params=dict()) + if pay_status.get("paid"): + # Checks to do: + # + # - check that the paid article is actually the one + # mentioned in the requested URI. + # + # - check if the article was refunded before, and act + # accordingly. + # + + # FLOW HERE == ARTICLE PAID AND CAN BE SHOWN. + + # Put the article in the cache. + paid_articles_cache.set(session_id + "-" + article_name, order_id) + + ## + # Finally return the article. + return render_article(article_name, data, order_id) + + elif pay_status.get("already_paid_order_id") is not None: + return flask.redirect( + flask.url_for( + "article", + article_name=article_name, + order_id=pay_status.get("already_paid_order_id") + ) + ) + else: + ## + # Redirect the browser to a page where the wallet can + # run the payment protocol. + taler_pay_uri = pay_status["taler_pay_uri"] + qrcode_svg = get_qrcode_svg(taler_pay_uri) + check_status_url_enc = urllib.parse.quote( + flask.url_for( + "check_status", order_id=order_id, session_id=session_id + ) + ) + content = flask.render_template( + "templates/request_payment.html", + article_name=article_name, + taler_pay_uri=taler_pay_uri, + qrcode_svg=qrcode_svg, + check_status_url_enc=check_status_url_enc + ) + headers = {"Taler": taler_pay_uri} + resp = flask.Response(content, status=402, headers=headers) + return resp + + # no pay_redirect but article not paid, this should never happen! + err_abort(500, message="Internal error, invariant failed", json=pay_status) |