summaryrefslogtreecommitdiff
path: root/talermerchantdemos/blog/blog.py
diff options
context:
space:
mode:
authorMS <ms@taler.net>2020-07-22 14:53:45 +0200
committerMS <ms@taler.net>2020-07-22 14:53:45 +0200
commit2d97ecc2c1ac605ca49e8a866b309daaeb7a831c (patch)
tree173f7917c5d0af822d2d51ed491c3cf2d8eaf23f /talermerchantdemos/blog/blog.py
downloadtaler-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.py414
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)