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)