taler-merchant-demos

Python-based Frontends for the Demonstration Web site
Log | Files | Refs | Submodules | README | LICENSE

blog.py (21089B)


      1 ##
      2 # This file is part of GNU Taler.
      3 # Copyright (C) 2014-2020 Taler Systems SA
      4 #
      5 # TALER is free software; you can redistribute it and/or modify it under the
      6 # terms of the GNU Lesser General Public License as published by the Free Software
      7 # Foundation; either version 2.1, or (at your option) any later version.
      8 #
      9 # TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11 # A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more details.
     12 #
     13 # You should have received a copy of the GNU Lesser General Public License along with
     14 # GNU TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 #
     16 # @author Florian Dold
     17 # @author Marcello Stanisci
     18 # @brief Implementation of a Taler-compatible blog.
     19 
     20 import urllib.parse
     21 import logging
     22 import os
     23 import traceback
     24 import uuid
     25 import base64
     26 import flask
     27 from flask import request, url_for
     28 from flask_babel import Babel
     29 from flask_babel import gettext
     30 from werkzeug.middleware.proxy_fix import ProxyFix
     31 import time
     32 import sys
     33 from ..appconfig import load_taler_config
     34 from ..blog.content import ARTICLES, get_article_contents
     35 from talermerchantdemos.httpcommon import (
     36     backend_get,
     37     backend_get_with_status,
     38     backend_post,
     39     backend_payment_url,
     40     Deadline,
     41     BackendException,
     42     make_utility_processor,
     43     get_locale,
     44 )
     45 
     46 if not sys.version_info.major == 3 and sys.version_info.minor >= 6:
     47     print("Python 3.6 or higher is required.")
     48     print(
     49         "You are using Python {}.{}.".format(
     50             sys.version_info.major, sys.version_info.minor
     51         )
     52     )
     53     sys.exit(1)
     54 
     55 app = flask.Flask(__name__, template_folder="../templates", static_folder="../static")
     56 app.wsgi_app = ProxyFix(app.wsgi_app, x_host=1, x_prefix=1)
     57 app.debug = True
     58 app.secret_key = base64.b64encode(os.urandom(64)).decode("utf-8")
     59 
     60 logger = logging.getLogger(__name__)
     61 
     62 config = load_taler_config()
     63 
     64 CURRENCY = config["taler"]["currency"].value_string(required=True)
     65 
     66 ARTICLE_AMOUNT = CURRENCY + ":0.5"
     67 BACKEND_URL = config["frontend-demo-blog"]["backend_url"].value_string(required=True)
     68 APIKEY = config["frontend-demo-blog"]["backend_apikey"].value_string(required=True)
     69 
     70 conf_tokens = config["frontend-demo-blog"]["enable_tokens"].value_string(default="NO")
     71 ENABLE_TOKENS = "yes" == config["frontend-demo-blog"]["enable_tokens"].value_string(
     72     default="NO"
     73 )
     74 
     75 SUBSCRIPTION_AMOUNT = CURRENCY + ":10"
     76 SUBSCRIPTION_SLUG_PREFIX = "blog_abo_"
     77 
     78 BABEL_TRANSLATION_DIRECTORIES = "../translations"
     79 
     80 app.config.from_object(__name__)
     81 babel = Babel(app, locale_selector=get_locale)
     82 
     83 # Add context processor that will make additional variables
     84 # and functions available in the template.
     85 app.context_processor(
     86     make_utility_processor("blog", os.environ.get("TALER_ENV_URL_MERCHANT_BLOG"))
     87 )
     88 
     89 
     90 def req_add_cookie_check():
     91     current_url = list(urllib.parse.urlparse(flask.request.base_url))
     92     args_writable = flask.request.args.copy()
     93     # Adding the used param.
     94     args_writable.update(dict(expect_state="yes"))
     95     current_url[4] = urllib.parse.urlencode(args_writable)
     96     # Stringify the result.
     97     return urllib.parse.urlunparse(current_url)
     98 
     99 
    100 def req_rm_cookie_check():
    101     current_url = list(urllib.parse.urlparse(flask.request.base_url))
    102     args_writable = flask.request.args.copy()
    103     # Stripping the used param.
    104     args_writable.pop("expect_state")
    105     current_url[4] = urllib.parse.urlencode(args_writable)
    106     # Stringify the result.
    107     return urllib.parse.urlunparse(current_url)
    108 
    109 
    110 def err_abort(abort_status_code, **params):
    111     """
    112     Return a error response to the client.
    113 
    114     @param abort_status_code status code to return along the response.
    115     @param params _kw_ arguments to passed verbatim to the templating engine.
    116     """
    117     t = flask.render_template("blog-error.html.j2", **params)
    118     flask.abort(flask.make_response(t, abort_status_code))
    119 
    120 
    121 def refundable(pay_status):
    122     refunded = pay_status.get("refunded")
    123     refund_deadline = pay_status.get("contract_terms", {}).get("refund_deadline")
    124     assert refunded != None and refund_deadline
    125     t_s = refund_deadline.get("t_s")
    126     # FIXME: do not use assert here!
    127     assert t_s
    128     rd = Deadline(t_s * 1000)
    129     if not refunded and not rd.isExpired():
    130         return True
    131     return False
    132 
    133 
    134 ##
    135 # "Fallback" exception handler to capture all the unmanaged errors.
    136 #
    137 # @param e the Exception object, currently unused.
    138 # @return flask-native response object carrying the error message
    139 #         (and execution stack!).
    140 @app.errorhandler(Exception)
    141 def internal_error(e):
    142     return flask.render_template(
    143         "blog-error.html.j2",
    144         page_title=gettext("GNU Taler Demo: Error"),
    145         message=gettext("Internal error"),
    146         stack=traceback.format_exc(),
    147     )
    148 
    149 
    150 ##
    151 # Serve the main index page, redirecting to /<lang>/
    152 #
    153 # @return response object of the index page.
    154 @app.route("/")
    155 def index():
    156     default = "en"
    157     translations = [x.language for x in babel.list_translations()]
    158     target = flask.request.accept_languages.best_match(translations, default)
    159     return flask.redirect(url_for("index") + target + "/", code=302)
    160 
    161 
    162 ##
    163 # Serve the /favicon.ico requests.
    164 #
    165 # @return the favicon.ico file.
    166 @app.route("/favicon.ico")
    167 def favicon():
    168     logger.info("will look into: " + os.path.join(app.root_path, "static"))
    169     return flask.send_from_directory(
    170         os.path.join(app.root_path, "static"),
    171         "favicon.ico",
    172         mimetype="image/vnd.microsoft.ico",
    173     )
    174 
    175 
    176 ##
    177 # Serve the main index page for a particular language.
    178 #
    179 # @return response object of the index page.
    180 @app.route("/<lang>/")
    181 def start(lang):
    182 
    183     # get_locale defaults to english, hence the
    184     # condition below happens only when lang is
    185     # wrong or unsupported, respond 404.
    186     if lang != get_locale():
    187         err_abort(
    188             404,
    189             message="Language {} not found".format(lang),
    190         )
    191 
    192     if lang in ARTICLES:
    193         translated = ARTICLES[lang]
    194     else:
    195         translated = {}
    196     return flask.render_template(
    197         "blog-index.html.j2",
    198         merchant_currency=CURRENCY,
    199         page_title=gettext("GNU Taler Demo: Essay Shop"),
    200         articles=translated.values(),
    201     )
    202 
    203 
    204 @app.route("/<lang>/confirm-refund/<order_id>", methods=["GET"])
    205 def confirm_refund(lang, order_id):
    206     session_id = flask.session.get("session_id", "")
    207     pay_status = backend_get(
    208         BACKEND_URL,
    209         f"private/orders/{order_id}",
    210         params=dict(session_id=session_id),
    211         auth_token=APIKEY,
    212     )
    213     order_status = pay_status.get("order_status")
    214     if order_status != "paid":
    215         err_abort(
    216             400,
    217             message=gettext("Cannot refund unpaid article"),
    218         )
    219     article_name = pay_status["contract_terms"]["extra"]["article_name"]
    220 
    221     if not refundable(pay_status):
    222         return flask.render_template(
    223             "blog-error.html.j2",
    224             page_title=gettext("GNU Taler Demo: Error"),
    225             message=gettext("Article is not anymore refundable"),
    226         )
    227     return flask.render_template(
    228         "blog-confirm-refund.html.j2",
    229         page_title=gettext("GNU Taler Demo: Confirm refund"),
    230         article_name=article_name,
    231         order_id=order_id,
    232     )
    233 
    234 
    235 ##
    236 # Triggers the refund by serving /refund/test?order_id=XY.
    237 # Will be triggered by a "refund button".
    238 #
    239 # @param order_id the order ID of the transaction to refund.
    240 # @return the following errors (named by HTTP response code):
    241 #         - 400: order unknown
    242 #         - 402: the refund was asked on an unpaid article.
    243 #         - 302: in the successful case, a redirection to the
    244 #           "refund URL" is returned; then the wallet will run
    245 #           the refund protocol in a transparent way.
    246 @app.route("/refund/<order_id>", methods=["POST"])
    247 def refund(order_id):
    248     if not order_id:
    249         return flask.jsonify(dict(error="Aborting refund: order unknown")), 400
    250     session_id = flask.session.get("session_id", "")
    251     pay_status = backend_get(
    252         BACKEND_URL,
    253         f"private/orders/{order_id}",
    254         params=dict(session_id=session_id),
    255         auth_token=APIKEY,
    256     )
    257     order_status = pay_status.get("order_status")
    258 
    259     if order_status != "paid":
    260         err_abort(
    261             402,
    262             message=gettext("You did not pay for this article (nice try!)"),
    263             json=pay_status,
    264         )
    265     if not refundable(pay_status):
    266         err_abort(
    267             403, message=gettext("Item not refundable (anymore)"), json=pay_status
    268         )
    269     refund_spec = dict(reason="Demo reimbursement", refund=ARTICLE_AMOUNT)
    270     backend_post(
    271         BACKEND_URL, f"private/orders/{order_id}/refund", refund_spec, auth_token=APIKEY
    272     )
    273     return flask.redirect(pay_status["order_status_url"])
    274 
    275 
    276 ##
    277 # Render the article after a successful purchase.
    278 #
    279 # @param article_name _slugged_ (= spaces converted to underscores) article title.
    280 # @param lang language the article is to be in
    281 # @param data image filename to return along the article.
    282 # @param order_id the ID of the order where this article got purchased.
    283 #        (Will be put in the refund-request form action, since any article
    284 #         will also have a "refund button" aside.)
    285 # @return the following errors (named by HTTP return code):
    286 #         - 500: file for article not found.
    287 #         - 404: supplemental @a data not found.
    288 #         In the successful case, a response object carrying the
    289 #         article in it will be returned.
    290 def render_article(article_name, lang, data, order_id, refundable):
    291     article_info = ARTICLES[lang].get(article_name)
    292     if article_info is None:
    293         m = gettext("Internal error: Files for article ({}) not found.").format(
    294             article_name
    295         )
    296         err_abort(500, message=m)
    297     if data is not None:
    298         # if data in article_info.extra_files:
    299         #    return flask.send_file(get_image_file(data))
    300         # m = gettext("Supplemental file ({}) for article ({}) not found.").format(
    301         #    data, article_name
    302         # )
    303         err_abort(404, message=m)
    304     # the order_id is needed for refunds
    305     article_contents = get_article_contents(article_info)
    306     return flask.render_template(
    307         "blog-article-frame.html.j2",
    308         page_title=gettext("GNU Taler Demo: Article"),
    309         article_contents=article_contents,
    310         article_name=article_info.title,
    311         order_id=order_id,
    312         refundable=refundable,
    313     )
    314 
    315 
    316 ##
    317 # Setup a fresh order with the backend.
    318 #
    319 # @param article_name which article the order is for
    320 # @param lang which language to use
    321 #
    322 def post_order(article_name, article_url, session_id, lang):
    323     article_info = ARTICLES[lang].get(article_name)
    324     summary = f"Essay: {article_info.title}"
    325     choices = [
    326         # regular price
    327         {
    328             "amount": ARTICLE_AMOUNT,
    329             "description": "Buy an individual article",
    330         },
    331     ]
    332     if ENABLE_TOKENS:
    333         tok_choices = [
    334             # buy monthly abo
    335             {
    336                 "amount": SUBSCRIPTION_AMOUNT,
    337                 "description": "Buy one month of unlimited access",
    338                 "outputs": [
    339                     {
    340                         "type": "token",
    341                         "token_family_slug": SUBSCRIPTION_SLUG_PREFIX + lang,
    342                     }
    343                 ],
    344             },
    345             # access with monthly abo
    346             {
    347                 "amount": CURRENCY + ":0",
    348                 "inputs": [
    349                     {
    350                         "type": "token",
    351                         "token_family_slug": SUBSCRIPTION_SLUG_PREFIX + lang,
    352                     }
    353                 ],
    354                 "outputs": [
    355                     {
    356                         "type": "token",
    357                         "token_family_slug": SUBSCRIPTION_SLUG_PREFIX + lang,
    358                     }
    359                 ],
    360             },
    361         ]
    362         choices.extend(tok_choices)
    363     order = {
    364         "version": 1,
    365         "extra": {"article_name": article_name},
    366         "fulfillment_url": article_url,
    367         "public_reorder_url": article_url,
    368         "summary": summary,
    369         "session_id": session_id,
    370         "choices": choices,
    371         # FIXME: add support for i18n of summary!
    372         # 10 minutes time for a refund
    373         "wire_transfer_deadline": {"t_s": int(time.time() + 15 * 30)},
    374     }
    375     order_resp = backend_post(
    376         BACKEND_URL,
    377         "private/orders",
    378         dict(order=order, refund_delay=dict(d_us=1000 * 1000 * 120)),
    379         auth_token=APIKEY,
    380     )
    381     return order_resp
    382 
    383 
    384 ##
    385 # Trigger a article purchase.  The logic follows the main steps:
    386 #
    387 # 1. Always check if the article was paid already, via the
    388 #    "/private/orders/$ORDER_ID" API from the backend.
    389 # 2. If so, return the article.
    390 # 3. If not, redirect the browser to a page where the
    391 #    wallet will initiate the payment protocol.
    392 #
    393 # @param article_name (slugged) article title.
    394 # @param data filename of a supplement data (image/sound/..)
    395 # @return the following errors might occur (named by HTTP response code):
    396 #         - 402: @a article_name does not correspond to the @a order_id
    397 #                of a PAYED article.
    398 #         - 500: neither the article was paid, nor a payment was triggered.
    399 #         - 400: a invalid order_id was passed along the GET parameters.
    400 #         In the successful case, either the article is returned, or
    401 #         the browser gets redirected to a page where the wallet can
    402 #         send the payment.
    403 @app.route("/<lang>/essay/<article_name>")
    404 @app.route("/<lang>/essay/<article_name>/data/<data>")
    405 @app.route("/essay/<article_name>/data/<data>")
    406 def article(article_name, lang=None, data=None):
    407     # We use an explicit session ID so that each payment (or payment replay) is
    408     # bound to a browser.  This forces re-play and prevents sharing the article
    409     # by just sharing the URL.  flask.session is transparently set by Flask, when
    410     # the user agent supports cookies.  All the key-value pairs associated to it
    411     # are only stored in the server.
    412     session_id = flask.session.get("session_id")
    413     maybe_expect_state = request.args.get("expect_state")
    414 
    415     # Disabled for now, see https://bugs.gnunet.org/view.php?id=8137
    416     # and https://bugs.gnunet.org/view.php?id=8353
    417     new_if_refunded = request.args.get("new_if_refunded")
    418 
    419     current_order_id = flask.request.cookies.get("order_id")
    420     article_url = flask.request.base_url
    421 
    422     if not session_id:
    423         # If expect_state = yes then session_id should be set already
    424         # this is a way to check that the client supports cookies
    425         if maybe_expect_state == "yes":
    426             error_page = flask.render_template(
    427                 "blog-error.html.j2",
    428                 page_title=gettext("GNU Taler Demo: Error"),
    429                 message=gettext("Please enable cookies."),
    430             )
    431             return flask.make_response(error_page, 412)
    432 
    433         # first time setting session_id
    434         # check if browser support cookies with a flag
    435         session_id = flask.session["session_id"] = str(uuid.uuid4())
    436         return flask.redirect(req_add_cookie_check(), code=302)
    437 
    438     # If session is present then we know that cookies are enabled
    439     # remove the flag if present
    440     if maybe_expect_state == "yes":
    441         return flask.redirect(req_rm_cookie_check(), code=302)
    442 
    443     ############################
    444     # user has a session and cookie works
    445     #
    446     # check if we can already render the article
    447     ############################
    448 
    449     # if an order_id is present then render if paid or refunded
    450     if current_order_id is not None:
    451         status, current_order = backend_get_with_status(
    452             BACKEND_URL,
    453             f"private/orders/{current_order_id}",
    454             params=dict(session_id=session_id),
    455             auth_token=APIKEY,
    456         )
    457 
    458         if status == 200:
    459             if current_order.get("order_status") == "paid" and not current_order.get(
    460                 "refunded"
    461             ):
    462                 return render_article(
    463                     article_name,
    464                     lang,
    465                     data,
    466                     current_order_id,
    467                     refundable(current_order),
    468                 )
    469 
    470             # Checking repurchase case. That happens when the client
    471             # visits this page in the same session where the article
    472             # was paid already.
    473             ai = current_order.get("already_paid_order_id")
    474             au = current_order.get("already_paid_fulfillment_url")
    475 
    476             if ai is not None:
    477                 print("== Merchant says 'see other': ", ai, au)
    478                 response = flask.redirect(article_url)
    479                 response.set_cookie(
    480                     "order_id",
    481                     ai,
    482                     path=urllib.parse.quote(url_for("index") + f"essay/{article_name}"),
    483                 )
    484                 response.set_cookie(
    485                     "order_id",
    486                     ai,
    487                     path=urllib.parse.quote(
    488                         url_for("index") + f"{lang}/essay/{article_name}"
    489                     ),
    490                 )
    491                 return response
    492 
    493             # If new_if_refunded == "yes" the user already acknowledge the
    494             # state of the current order and is asking for a new one.
    495             if current_order.get("refunded") and new_if_refunded != "yes":
    496                 return flask.render_template(
    497                     "blog-article-refunded.html.j2",
    498                     page_title=gettext("GNU Taler Demo: Refunded"),
    499                     article_name=article_name,
    500                     article_lang=lang,
    501                     order_id=current_order_id,
    502                 )
    503 
    504         elif status != 404:
    505             # not found may be normal, could means that
    506             # merchant forgot about the order becuase
    507             # it was a long time without being paid
    508             raise BackendException(
    509                 message=gettext("Backend returned error status"),
    510                 backend_status=status,
    511                 backend_json=current_order,
    512             )
    513 
    514     # Current order is not present or unpaid
    515     # Check if there is a paid but not refunded order in this session
    516     list_resp = backend_get(
    517         BACKEND_URL,
    518         f"private/orders",
    519         params=dict(session_id=session_id, fulfillment_url=article_url, refunded="no"),
    520         auth_token=APIKEY,
    521     )
    522 
    523     already_paid_order = None
    524     for order in list_resp.get("orders"):
    525         if order.get("paid"):
    526             already_paid_order = order
    527             break
    528 
    529     if already_paid_order is not None:
    530         # Found one, now this is the current order.
    531         print("== Already paid order found", already_paid_order)
    532         order_id = already_paid_order.get("order_id")
    533         response = flask.redirect(article_url)
    534         response.set_cookie(
    535             "order_id",
    536             order_id,
    537             path=urllib.parse.quote(url_for("index") + f"essay/{article_name}"),
    538         )
    539         response.set_cookie(
    540             "order_id",
    541             order_id,
    542             path=urllib.parse.quote(url_for("index") + f"{lang}/essay/{article_name}"),
    543         )
    544         return response
    545 
    546     ############################
    547     # We couln't find a paid order
    548     #
    549     # Note that it could be the case that the user is still paying
    550     # an order with another device, in other browser on the same
    551     # session or claimed in the same brower.
    552     # Still, creating an order is cheap and we can safely redirect
    553     # to a payment page and relay on repurchase detection to avoid
    554     # double payments.
    555     #
    556     # create a new order and ask for payment
    557     ############################
    558 
    559     order_resp = post_order(article_name, article_url, session_id, lang)
    560     order_id = order_resp["order_id"]
    561     token = order_resp["token"]
    562 
    563     redirect_url = backend_payment_url(
    564         BACKEND_URL, f"orders/{order_id}", session_id, token
    565     )
    566     print("new order URL", redirect_url)
    567     response = flask.redirect(redirect_url)
    568     response.set_cookie(
    569         "order_id",
    570         order_id,
    571         path=urllib.parse.quote(url_for("index") + f"essay/{article_name}"),
    572     )
    573     response.set_cookie(
    574         "order_id",
    575         order_id,
    576         path=urllib.parse.quote(url_for("index") + f"{lang}/essay/{article_name}"),
    577     )
    578 
    579     return response
    580 
    581 
    582 @app.errorhandler(500)
    583 def handler_500(e):
    584     return flask.render_template(
    585         "blog-error.html.j2",
    586         page_title=gettext("GNU Taler Demo: Error"),
    587         message=gettext("Internal server error"),
    588     )
    589 
    590 
    591 @app.errorhandler(404)
    592 def handler_404(e):
    593     return flask.render_template(
    594         "blog-error.html.j2",
    595         page_title=gettext("GNU Taler Demo: Error"),
    596         message=gettext("Page not found"),
    597     )
    598 
    599 
    600 @app.errorhandler(BackendException)
    601 def handler_backend_exception(e):
    602     t = flask.render_template(
    603         "blog-error.html.j2",
    604         message=e.args[0],
    605         page_title=gettext("GNU Taler Demo: Error"),
    606         json=e.backend_json,
    607         status_code=e.backend_status,
    608     )
    609     return flask.make_response(t, 500)