##
# 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
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 ..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,
make_utility_processor,
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):
"""
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.
"""
t = flask.render_template("blog-error.html.j2", **params)
flask.abort(flask.make_response(t, abort_status_code))
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_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(
"You are using Python {}.{}.".format(
sys.version_info.major, sys.version_info.minor
)
)
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__)
try:
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)
ARTICLE_AMOUNT = CURRENCY + ":0.5"
BACKEND_URL = urljoin(BACKEND_BASE_URL, "instances/blog/")
BABEL_TRANSLATION_DIRECTORIES = "../translations"
app.config.from_object(__name__)
babel = Babel(app)
babel.localeselector(get_locale)
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)
)
# Add context processor that will make additional variables
# and functions available in the template.
app.context_processor(make_utility_processor("blog", os.environ.get ("TALER_ENV_URL_MERCHANT_BLOG")))
##
# "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(
"blog-error.html.j2",
page_title=gettext("GNU Taler Demo: Error"),
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(url_for ('index') + 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",
)
##
# Serve the main index page for a particular language.
#
# @return response object of the index page.
@app.route("//")
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:
translated = {}
return flask.render_template(
"blog-index.html.j2",
merchant_currency=CURRENCY,
page_title=gettext("GNU Taler Demo: Essay Shop"),
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),
auth_token=APIKEY,
)
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(
"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,
)
##
# 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),
auth_token=APIKEY,
)
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)
backend_post(
BACKEND_URL, f"private/orders/{order_id}/refund", refund_spec, auth_token=APIKEY
)
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
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_info.title,
order_id=order_id,
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, 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=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_s=int(time.time() + 15 * 30)),
)
order_resp = backend_post(
BACKEND_URL,
"private/orders",
dict(order=order, refund_delay=dict(d_us=1000 * 1000 * 120)),
auth_token=APIKEY,
)
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. 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")
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:
# 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,
)
# 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",
params=dict(session_id=session_id, fulfillment_url=article_url, refunded="no"),
auth_token=APIKEY,
)
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",
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}")
)
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}")
)
return response
@app.errorhandler(500)
def handler_500(e):
return flask.render_template(
"blog-error.html.j2",
page_title=gettext("GNU Taler Demo: Error"),
message=gettext("Internal server error"),
)
@app.errorhandler(404)
def handler_404(e):
return flask.render_template(
"blog-error.html.j2",
page_title=gettext("GNU Taler Demo: Error"),
message=gettext("Page not found"),
)
@app.errorhandler(BackendException)
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,
)
return flask.make_response(t, 500)