##
# 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")
)