## # This file is part of GNU Taler. # Copyright (C) 2014-2020 Taler Systems SA # # 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 # # @author Florian Dold # @author Marcello Stanisci # @brief Implementation of a Taler-compatible blog. import urllib.parse import logging import os import traceback import uuid import base64 import flask from flask import request from flask_babel import Babel from flask_babel import refresh from flask_babel import force_locale from flask_babel import gettext import time import sys from urllib.parse import urljoin, urlencode, urlparse from taler.util.talerconfig import TalerConfig, ConfigurationError from ..blog.content import ARTICLES, get_article_file, get_image_file from talermerchantdemos.httpcommon import backend_get, backend_post, self_localized, err_abort, Deadline 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) 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( "You are using Python {}.{}.".format( sys.version_info.major, sys.version_info.minor ) ) sys.exit(1) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) app = flask.Flask( __name__, template_folder=BASE_DIR, static_folder=BASE_DIR + "/../static/" ) app.debug = True app.secret_key = base64.b64encode(os.urandom(64)).decode("utf-8") 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) except ConfigurationError as ce: print(ce) exit(1) ARTICLE_AMOUNT = CURRENCY + ":0.5" BACKEND_URL = urljoin(BACKEND_BASE_URL, "instances/blog/") app.config.from_object(__name__) babel = Babel(app) LOGGER.info("Using translations from:" + ':'.join(list(babel.translation_directories))) translations = [str(translation) for translation in babel.list_translations()] if not 'en' in translations: translations.append('en') LOGGER.info("Operating with the following translations available: " + ' '.join(translations)) app.jinja_env.globals.update(self_localized=self_localized) ## # 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) ## # "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.j2", message=gettext("Internal error"), stack=traceback.format_exc() ) ## # Serve the main index page, redirecting to // # # @return response object of the index page. @app.route("/") def index(): default = 'en' target = flask.request.accept_languages.best_match(translations, default) return flask.redirect("/" + target + "/", code=302) ## # Serve the /favicon.ico requests. # # @return the favicon.ico file. @app.route("/favicon.ico") def favicon(): LOGGER.info("will look into: " + os.path.join(app.root_path, 'static')) return flask.send_from_directory( os.path.join(app.root_path, 'static'), "favicon.ico", mimetype="image/vnd.microsoft.ico" ) @babel.localeselector def get_locale(): parts = request.path.split('/', 2) if (2 >= len(parts)): # Totally unexpected path format, do not localize return "en" lang = parts[1] if lang in translations: return lang return "en" ## # Serve the main index page for a particular language. # # @return response object of the index page. @app.route("//") def start(lang): if lang in ARTICLES: translated=ARTICLES[lang] else: translated={} return flask.render_template( "templates/index.html.j2", lang=lang, merchant_currency=CURRENCY, articles=translated.values() ) @app.route("//confirm-refund/", methods=["GET"]) def confirm_refund(lang, order_id): session_id = flask.session.get("session_id", "") pay_status = backend_get( BACKEND_URL, f"private/orders/{order_id}", params=dict(session_id=session_id) ) order_status = pay_status.get("order_status") if order_status != "paid": err_abort( 400, message=gettext("Cannot refund unpaid article"), ) article_name = pay_status["contract_terms"]["extra"]["article_name"] if not refundable(pay_status): return flask.render_template( "templates/error.html.j2", message=gettext("Article is not anymore refundable") ) return flask.render_template( "templates/confirm_refund.html.j2", 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: order unknown # - 402: the refund was asked on an unpaid article. # - 302: 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/", methods=["POST"]) def refund(order_id): if not order_id: return flask.jsonify(dict(error="Aborting refund: order unknown")), 400 session_id = flask.session.get("session_id", "") pay_status = backend_get( BACKEND_URL, f"private/orders/{order_id}", params=dict(session_id=session_id) ) order_status = pay_status.get("order_status") if order_status != "paid": err_abort( 402, message=gettext("You did not pay for this article (nice try!)"), json=pay_status ) if not refundable(pay_status): err_abort( 403, message=gettext("Item not refundable (anymore)"), json=pay_status ) refund_spec = dict(reason="Demo reimbursement", refund=ARTICLE_AMOUNT) resp = backend_post(BACKEND_URL, f"private/orders/{order_id}/refund", refund_spec) return flask.redirect(pay_status["order_status_url"]) ## # Render the article after a successful purchase. # # @param article_name _slugged_ (= spaces converted to underscores) article title. # @param lang language the article is to be in # @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, lang, data, order_id, refundable): article_info = ARTICLES[lang].get(article_name) if article_info is None: m = gettext("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 = gettext("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.j2", article_file=get_article_file(article_info), article_name=article_name, order_id=order_id, lang=lang, refundable=refundable ) ## # Setup a fresh order with the backend. # # @param article_name which article the order is for # @param lang which language to use # def post_order(article_name,lang): order = dict( amount=ARTICLE_AMOUNT, extra=dict(article_name=article_name,lang=lang), fulfillment_url=flask.request.base_url, summary="Essay: " + article_name.replace("_", " "), # 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)), ) order_resp = backend_post( BACKEND_URL, "private/orders", dict(order=order, refund_delay=dict(d_ms=1000 * 120))) return order_resp ## # Trigger a article purchase. The logic follows the main steps: # # 1. Always check if the article was paid already, via the # "/private/orders/$ORDER_ID" 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/") @app.route("//essay//data/") @app.route("/essay//data/") 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. session_id = flask.session.get("session_id") order_id = flask.request.cookies.get("order_id") 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"] # Ask the backend for the status of the payment pay_status = backend_get( BACKEND_URL, f"private/orders/{order_id}", params=dict(session_id=session_id) ) 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) ) 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( "templates/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) response.set_cookie( "order_id", ai, path=urllib.parse.quote(f"/essay/{article_name}") ) response.set_cookie( "order_id", ai, path=urllib.parse.quote(f"/{lang}/essay/{article_name}") ) return response # 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=urllib.parse.quote(f"/essay/{article_name}") ) response.set_cookie( "order_id", order_id, path=urllib.parse.quote(f"/{lang}/essay/{article_name}") ) return response @app.errorhandler(500) def handler(e): return flask.render_template( "templates/error.html.j2", message=gettext("Internal server error") ) @app.errorhandler(404) def handler(e): return flask.render_template( "templates/error.html.j2", message=gettext("Page not found") )