diff options
Diffstat (limited to 'talermerchantdemos/blog/blog.py')
-rw-r--r-- | talermerchantdemos/blog/blog.py | 297 |
1 files changed, 221 insertions, 76 deletions
diff --git a/talermerchantdemos/blog/blog.py b/talermerchantdemos/blog/blog.py index 9722206..bcd1bb8 100644 --- a/talermerchantdemos/blog/blog.py +++ b/talermerchantdemos/blog/blog.py @@ -24,19 +24,23 @@ import traceback import uuid import base64 import flask -from flask import request +import uwsgi +from flask import request, url_for from flask_babel import Babel from flask_babel import refresh from flask_babel import force_locale from flask_babel import gettext +from werkzeug.middleware.proxy_fix import ProxyFix import time import sys from urllib.parse import urljoin, urlencode, urlparse -from taler.util.talerconfig import TalerConfig, ConfigurationError +from ..util.talerconfig import TalerConfig, ConfigurationError from ..blog.content import ARTICLES, get_article_file, get_image_file from talermerchantdemos.httpcommon import ( backend_get, + backend_get_with_status, backend_post, + backend_payment_url, self_localized, Deadline, BackendException, @@ -44,6 +48,25 @@ from talermerchantdemos.httpcommon import ( get_locale, ) +def req_add_cookie_check(): + current_url = list(urllib.parse.urlparse(flask.request.base_url)) + args_writable = flask.request.args.copy() + # Adding the used param. + args_writable.update(dict(expect_state="yes")) + current_url[4] = urllib.parse.urlencode(args_writable) + # Stringify the result. + return urllib.parse.urlunparse(current_url) + + +def req_rm_cookie_check(): + current_url = list(urllib.parse.urlparse(flask.request.base_url)) + args_writable = flask.request.args.copy() + # Stripping the used param. + args_writable.pop("expect_state") + current_url[4] = urllib.parse.urlencode(args_writable) + # Stringify the result. + return urllib.parse.urlunparse(current_url) + def err_abort(abort_status_code, **params): """ @@ -60,14 +83,14 @@ def refundable(pay_status): refunded = pay_status.get("refunded") refund_deadline = pay_status.get("contract_terms", {}).get("refund_deadline") assert refunded != None and refund_deadline - t_ms = refund_deadline.get("t_ms") - assert t_ms - rd = Deadline(t_ms) + t_s = refund_deadline.get("t_s") + # FIXME: do not use assert here! + assert t_s + rd = Deadline(t_s * 1000) if not refunded and not rd.isExpired(): return True return False - if not sys.version_info.major == 3 and sys.version_info.minor >= 6: print("Python 3.6 or higher is required.") print( @@ -78,15 +101,17 @@ if not sys.version_info.major == 3 and sys.version_info.minor >= 6: sys.exit(1) app = flask.Flask(__name__, template_folder="../templates", static_folder="../static") +app.wsgi_app = ProxyFix(app.wsgi_app, x_host=1, x_prefix=1) app.debug = True app.secret_key = base64.b64encode(os.urandom(64)).decode("utf-8") +logging.basicConfig() LOGGER = logging.getLogger(__name__) -TC = TalerConfig.from_env() try: - 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) + + BACKEND_BASE_URL = uwsgi.opt["backend_url"].decode("utf-8") + CURRENCY = uwsgi.opt["currency"].decode("utf-8") + APIKEY = uwsgi.opt["apikey"].decode("utf-8") except ConfigurationError as ce: print(ce) exit(1) @@ -110,7 +135,7 @@ LOGGER.info( # Add context processor that will make additional variables # and functions available in the template. -app.context_processor(make_utility_processor("blog")) +app.context_processor(make_utility_processor("blog", os.environ.get ("TALER_ENV_URL_MERCHANT_BLOG"))) ## @@ -123,6 +148,7 @@ app.context_processor(make_utility_processor("blog")) def internal_error(e): return flask.render_template( "blog-error.html.j2", + page_title=gettext("GNU Taler Demo: Error"), message=gettext("Internal error"), stack=traceback.format_exc(), ) @@ -136,7 +162,7 @@ def internal_error(e): def index(): default = "en" target = flask.request.accept_languages.best_match(translations, default) - return flask.redirect("/" + target + "/", code=302) + return flask.redirect(url_for ('index') + target + "/", code=302) ## @@ -159,6 +185,16 @@ def favicon(): # @return response object of the index page. @app.route("/<lang>/") def start(lang): + + # get_locale defaults to english, hence the + # condition below happens only when lang is + # wrong or unsupported, respond 404. + if lang != get_locale(): + err_abort( + 404, + message="Language {} not found".format(lang), + ) + if lang in ARTICLES: translated = ARTICLES[lang] else: @@ -166,6 +202,7 @@ def start(lang): return flask.render_template( "blog-index.html.j2", merchant_currency=CURRENCY, + page_title=gettext("GNU Taler Demo: Essay Shop"), articles=translated.values(), ) @@ -190,10 +227,12 @@ def confirm_refund(lang, order_id): if not refundable(pay_status): return flask.render_template( "blog-error.html.j2", + page_title=gettext("GNU Taler Demo: Error"), message=gettext("Article is not anymore refundable"), ) return flask.render_template( "blog-confirm-refund.html.j2", + page_title=gettext("GNU Taler Demo: Confirm refund"), article_name=article_name, order_id=order_id, ) @@ -272,8 +311,9 @@ def render_article(article_name, lang, data, order_id, refundable): article_contents = open(get_article_file(article_info)).read() return flask.render_template( "blog-article-frame.html.j2", + page_title=gettext("GNU Taler Demo: Article"), article_contents=article_contents, - article_name=article_name, + article_name=article_info.title, order_id=order_id, refundable=refundable, ) @@ -285,22 +325,24 @@ def render_article(article_name, lang, data, order_id, refundable): # @param article_name which article the order is for # @param lang which language to use # -def post_order(article_name, lang): - name_decoded = urllib.parse.unquote(article_name).replace("_", " ") - summary = f"Essay: {name_decoded}" +def post_order(article_name, article_url, session_id, lang): + article_info = ARTICLES[lang].get(article_name) + summary = f"Essay: {article_info.title}" order = dict( amount=ARTICLE_AMOUNT, extra=dict(article_name=article_name), - fulfillment_url=flask.request.base_url, + fulfillment_url=article_url, + public_reorder_url=article_url, summary=summary, + session_id=session_id, # FIXME: add support for i18n of summary! # 10 minutes time for a refund - wire_transfer_deadline=dict(t_ms=1000 * int(time.time() + 15 * 30)), + wire_transfer_deadline=dict(t_s=int(time.time() + 15 * 30)), ) order_resp = backend_post( BACKEND_URL, "private/orders", - dict(order=order, refund_delay=dict(d_ms=1000 * 120)), + dict(order=order, refund_delay=dict(d_us=1000 * 1000 * 120)), auth_token=APIKEY, ) return order_resp @@ -331,76 +373,176 @@ def post_order(article_name, lang): def article(article_name, lang=None, 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. + # by just sharing the URL. flask.session is transparently set by Flask, when + # the user agent supports cookies. All the key-value pairs associated to it + # are only stored in the server. session_id = flask.session.get("session_id") - order_id = flask.request.cookies.get("order_id") + maybe_expect_state = request.args.get("expect_state") + + # Disabled for now, see https://bugs.gnunet.org/view.php?id=8137 + # and https://bugs.gnunet.org/view.php?id=8353 + new_if_refunded = request.args.get("new_if_refunded") + + current_order_id = flask.request.cookies.get("order_id") + article_url = flask.request.base_url if not session_id: - session_id = flask.session["session_id"] = str(uuid.uuid4()) - order_id = None - ## - # First-timer; generate order first. - if not order_id: - if not lang: - err_abort(403, message=gettext("Direct access forbidden")) - order_resp = post_order(article_name, lang) - order_id = order_resp["order_id"] + # If expect_state = yes then session_id should be set already + # this is a way to check that the client supports cookies + if maybe_expect_state == "yes": + error_page = flask.render_template( + "blog-error.html.j2", + page_title=gettext("GNU Taler Demo: Error"), + message=gettext("Please enable cookies."), + ) + return flask.make_response(error_page, 412) + + # first time setting session_id + # check if browser support cookies with a flag + session_id = flask.session["session_id"] = str(uuid.uuid4()) + return flask.redirect(req_add_cookie_check(), code=302) + + # If session is present then we know that cookies are enabled + # remove the flag if present + if maybe_expect_state == "yes": + return flask.redirect(req_rm_cookie_check(), code=302) + + ############################ + # user has a session and cookie works + # + # check if we can already render the article + ############################ + + # if an order_id is present then render if paid or refunded + if current_order_id is not None: + status, current_order = backend_get_with_status( + BACKEND_URL, + f"private/orders/{current_order_id}", + params=dict(session_id=session_id), + auth_token=APIKEY, + ) + + if status == 200: + if current_order.get("order_status") == "paid" and not current_order.get("refunded"): + return render_article( + article_name, + lang, + data, + current_order_id, + refundable(current_order) + ) + + # Checking repurchase case. That happens when the client + # visits this page in the same session where the article + # was paid already. + ai = current_order.get("already_paid_order_id") + au = current_order.get("already_paid_fulfillment_url") + + if ai is not None: + print("== Merchant says 'see other': ", ai, au) + response = flask.redirect(article_url) + response.set_cookie( + "order_id", + ai, + path=urllib.parse.quote(url_for ('index') + f"essay/{article_name}") + ) + response.set_cookie( + "order_id", + ai, + path=urllib.parse.quote(url_for ('index') + f"{lang}/essay/{article_name}") + ) + return response + + # If new_if_refunded == "yes" the user already acknowledge the + # state of the current order and is asking for a new one. + if current_order.get("refunded") and new_if_refunded != "yes": + return flask.render_template( + "blog-article-refunded.html.j2", + page_title=gettext("GNU Taler Demo: Refunded"), + article_name=article_name, + article_lang=lang, + order_id=current_order_id, + ) + + elif status != 404: + # not found may be normal, could means that + # merchant forgot about the order becuase + # it was a long time without being paid + raise BackendException( + message=gettext("Backend returned error status"), + backend_status=status, + backend_json=current_order, + ) - # Ask the backend for the status of the payment - pay_status = backend_get( + + # Current order is not present or unpaid + # Check if there is a paid but not refunded order in this session + list_resp = backend_get( BACKEND_URL, - f"private/orders/{order_id}", - params=dict(session_id=session_id), + f"private/orders", + params=dict(session_id=session_id, fulfillment_url=article_url, refunded="no"), auth_token=APIKEY, ) - order_status = pay_status.get("order_status") - if order_status == "claimed": - if not lang: - err_abort(403, message=gettext("Direct access forbidden")) - # Order already claimed, must setup fresh order - order_resp = post_order(article_name, lang) - order_id = order_resp["order_id"] - pay_status = backend_get( - BACKEND_URL, - f"private/orders/{order_id}", - params=dict(session_id=session_id), - auth_token=APIKEY, - ) - order_status = pay_status.get("order_status") - # This really must be 'unpaid' now... - - if order_status == "paid": - refunded = pay_status["refunded"] - if refunded: - return flask.render_template( - "blog-article-refunded.html.j2", - article_name=article_name, - order_id=order_id, - ) - response = render_article( - article_name, lang, data, order_id, refundable(pay_status) - ) - return response - - # Check if the customer came on this page via the - # re-purchase detection mechanism - ai = pay_status.get("already_paid_order_id") - au = pay_status.get("already_paid_fulfillment_url") - if ai is not None and au is not None: - response = flask.redirect(au) + + already_paid_order = None + for order in list_resp.get("orders"): + if order.get("paid"): + already_paid_order = order + break + + if already_paid_order is not None: + # Found one, now this is the current order. + print("== Already paid order found", already_paid_order) + order_id = already_paid_order.get("order_id") + response = flask.redirect(article_url) response.set_cookie( - "order_id", ai, path=urllib.parse.quote(f"/essay/{article_name}") + "order_id", + order_id, + path=urllib.parse.quote(url_for ('index') + f"essay/{article_name}") ) response.set_cookie( - "order_id", ai, path=urllib.parse.quote(f"/{lang}/essay/{article_name}") + "order_id", + order_id, + path=urllib.parse.quote(url_for ('index') + f"{lang}/essay/{article_name}") ) return response + + ############################ + # We couln't find a paid order + # + # Note that it could be the case that the user is still paying + # an order with another device, in other browser on the same + # session or claimed in the same brower. + # Still, creating an order is cheap and we can safely redirect + # to a payment page and relay on repurchase detection to avoid + # double payments. + # + # create a new order and ask for payment + ############################ + + order_resp = post_order(article_name, article_url, session_id, lang) + order_id = order_resp["order_id"] + token = order_resp["token"] + + redirect_url = backend_payment_url( + BACKEND_URL, + f"orders/{order_id}", + session_id, + token + ) + print("new order URL", redirect_url) + response = flask.redirect(redirect_url) + response.set_cookie( + "order_id", + order_id, + path=urllib.parse.quote(url_for ('index') + f"essay/{article_name}") + ) + response.set_cookie( + "order_id", + order_id, + path=urllib.parse.quote(url_for ('index') + f"{lang}/essay/{article_name}") + ) - # Redirect the browser to a page where the wallet can - # run the payment protocol. - response = flask.redirect(pay_status["order_status_url"]) - response.set_cookie("order_id", order_id, path=f"/essay/{article_name}") - response.set_cookie("order_id", order_id, path=f"/{lang}/essay/{article_name}") return response @@ -408,6 +550,7 @@ def article(article_name, lang=None, data=None): def handler_500(e): return flask.render_template( "blog-error.html.j2", + page_title=gettext("GNU Taler Demo: Error"), message=gettext("Internal server error"), ) @@ -416,6 +559,7 @@ def handler_500(e): def handler_404(e): return flask.render_template( "blog-error.html.j2", + page_title=gettext("GNU Taler Demo: Error"), message=gettext("Page not found"), ) @@ -425,6 +569,7 @@ def handler_backend_exception(e): t = flask.render_template( "blog-error.html.j2", message=e.args[0], + page_title=gettext("GNU Taler Demo: Error"), json=e.backend_json, status_code=e.backend_status, ) |