From 5b29c82bcd05efd43152ef0dd39e1d7f3b457933 Mon Sep 17 00:00:00 2001 From: Marcello Stanisci Date: Fri, 24 Nov 2017 20:00:37 +0100 Subject: actual blog logic --- talerblog/blog/blog.py | 248 ++++++++++++++++++++++++++++++++++++++++++++++ talerblog/blog/content.py | 131 ++++++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 talerblog/blog/blog.py create mode 100644 talerblog/blog/content.py diff --git a/talerblog/blog/blog.py b/talerblog/blog/blog.py new file mode 100644 index 0000000..02c6c2d --- /dev/null +++ b/talerblog/blog/blog.py @@ -0,0 +1,248 @@ +# This file is part of GNU TALER. +# Copyright (C) 2014-2017 INRIA +# +# 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 + + +""" +Implement URL handlers and payment logic for the blog merchant. +""" + +import flask +from urllib.parse import urljoin, urlencode, quote, parse_qsl +import requests +import logging +import os +import base64 +import random +import time +import json +import datetime +from pprint import pprint +from talerfrontends.talerconfig import TalerConfig +from talerfrontends.helpers import (make_url, + expect_parameter, join_urlparts, get_query_string, + backend_error) +from talerfrontends.blog.content import (articles, +get_article_file, get_image_file) + +logger = logging.getLogger(__name__) + +base_dir = os.path.dirname(os.path.abspath(__file__)) + +app = flask.Flask(__name__, template_folder=base_dir) +app.debug = True +app.secret_key = base64.b64encode(os.urandom(64)).decode('utf-8') + +tc = TalerConfig.from_env() + +BACKEND_URL = tc["frontends"]["backend"].value_string(required=True) +CURRENCY = tc["taler"]["currency"].value_string(required=True) +INSTANCE = tc["blog"]["instance"].value_string(required=True) +ARTICLE_AMOUNT = dict(value=1, fraction=0, currency=CURRENCY) + +app.config.from_object(__name__) + + +@app.context_processor +def utility_processor(): + def url(my_url): + return join_urlparts(flask.request.script_root, my_url) + def env(name, default=None): + return os.environ.get(name, default) + return dict(url=url, env=env) + + +@app.route("/") +def index(): + return flask.render_template("templates/index.html", + merchant_currency=CURRENCY, + articles=articles.values()) + + +@app.route("/javascript") +def javascript_licensing(): + return flask.render_template("templates/javascript.html") + +# Triggers the refund by serving /refund/test?order_id=XY. +# Will be triggered by a "refund button". +@app.route("/refund", methods=["GET", "POST"]) +def refund(): + if flask.request.method == "POST": + payed_articles = flask.session["payed_articles"] = flask.session.get("payed_articles", {}) + article_name = flask.request.form.get("article_name") + if not article_name: + return flask.jsonify(dict(error="No article_name found in form")), 400 + logger.info("Looking for %s to refund" % article_name) + order_id = payed_articles.get(article_name) + if not order_id: + return flask.jsonify(dict(error="Aborting refund: article not payed")), 401 + r = requests.post(urljoin(BACKEND_URL, "refund"), + json=dict(order_id=order_id, + refund=dict(value=1, fraction=0, currency=CURRENCY), + reason="Demo reimbursement", + instance=INSTANCE)) + if 200 != r.status_code: + return backend_error(r) + payed_articles[article_name] = "__refunded" + response = flask.make_response() + response.headers["X-Taler-Refund-Url"] = make_url("/refund", ("order_id", order_id)) + return response, 402 + + else: + order_id = expect_parameter("order_id", False) + if not order_id: + logger.error("Missing parameter 'order_id'") + return flask.jsonify(dict(error="Missing parameter 'order_id'")), 400 + r = requests.get(urljoin(BACKEND_URL, "refund"), params=dict(order_id=order_id, + instance=INSTANCE)) + if 200 != r.status_code: + return backend_error(r) + return flask.jsonify(r.json()), r.status_code + + +@app.route("/generate-contract", methods=["GET"]) +def generate_contract(): + article_name = expect_parameter("article_name") + pretty_name = article_name.replace("_", " ") + order = dict( + summary=pretty_name, + nonce=flask.request.args.get("nonce"), + amount=ARTICLE_AMOUNT, + max_fee=dict(value=1, fraction=0, currency=CURRENCY), + products=[ + dict( + description="Essay: " + pretty_name, + quantity=1, + product_id=0, + price=ARTICLE_AMOUNT, + ), + ], + fulfillment_url=make_url("/essay/" + quote(article_name)), + pay_url=make_url("/pay"), + merchant=dict( + instance=INSTANCE, + address="nowhere", + name="Kudos Inc.", + jurisdiction="none", + ), + extra=dict(article_name=article_name), + ) + r = requests.post(urljoin(BACKEND_URL, "proposal"), json=dict(order=order)) + if r.status_code != 200: + return backend_error(r) + proposal_resp = r.json() + return flask.jsonify(**proposal_resp) + + +@app.route("/cc-payment/") +def cc_payment(name): + return flask.render_template("templates/cc-payment.html", + article_name=name) + + +@app.route("/essay/") +@app.route("/essay//data/") +def article(name, data=None): + logger.info("processing %s" % name) + payed_articles = flask.session.get("payed_articles", {}) + + if payed_articles.get(name, "") == "__refunded": + return flask.render_template("templates/article_refunded.html", article_name=name) + + if name in payed_articles: + article = articles[name] + if article is None: + flask.abort(500) + if data is not None: + if data in article.extra_files: + return flask.send_file(get_image_file(data)) + else: + return "permission denied", 400 + return flask.render_template("templates/article_frame.html", + article_file=get_article_file(article), + article_name=name) + + contract_url = make_url("/generate-contract", ("article_name",name)) + response = flask.make_response(flask.render_template("templates/fallback.html"), 402) + response.headers["X-Taler-Contract-Url"] = contract_url + response.headers["X-Taler-Contract-Query"] = "fulfillment_url" + # Useless (?) header, as X-Taler-Contract-Url takes always (?) precedence + # over X-Offer-Url. This one might only be useful if the contract retrieval + # goes wrong. + response.headers["X-Taler-Offer-Url"] = make_url("/essay/" + quote(name)) + return response + + +@app.route("/pay", methods=["POST"]) +def pay(): + deposit_permission = flask.request.get_json() + if deposit_permission is None: + e = flask.jsonify(error="no json in body"), + return e, 400 + r = requests.post(urljoin(BACKEND_URL, "pay"), json=deposit_permission) + if 200 != r.status_code: + return backend_error(r) + proposal_data = r.json()["contract_terms"] + article_name = proposal_data["extra"]["article_name"] + payed_articles = flask.session["payed_articles"] = flask.session.get("payed_articles", {}) + if len(r.json()["refund_permissions"]) != 0: + # we had some refunds on the article purchase already! + logger.info("Article %s was refunded, before /pay" % article_name) + payed_articles[article_name] = "__refunded" + return flask.jsonify(r.json()), 200 + if not deposit_permission["order_id"]: + logger.error("order_id missing from deposit_permission!") + return flask.jsonify(dict(error="internal error: ask for refund!")), 500 + if article_name not in payed_articles: + logger.info("Article %s goes in state" % article_name) + payed_articles[article_name] = deposit_permission["order_id"] + return flask.jsonify(r.json()), 200 + + +@app.route("/history") +def history(): + qs = get_query_string().decode("utf-8") + url = urljoin(BACKEND_URL, "history") + r = requests.get(url, params=dict(parse_qsl(qs))) + if 200 != r.status_code: + return backend_error(r) + return flask.jsonify(r.json()), r.status_code + + +@app.route("/backoffice") +def track(): + response = flask.make_response(flask.render_template("templates/backoffice.html")) + return response + + +@app.route("/track/transfer") +def track_transfer(): + qs = get_query_string().decode("utf-8") + url = urljoin(BACKEND_URL, "track/transfer") + r = requests.get(url, params=dict(parse_qsl(qs))) + if 200 != r.status_code: + return backend_error(r) + return flask.jsonify(r.json()), r.status_code + + +@app.route("/track/order") +def track_order(): + qs = get_query_string().decode("utf-8") + url = urljoin(BACKEND_URL, "track/transaction") + r = requests.get(url, params=dict(parse_qsl(qs))) + if 200 != r.status_code: + return backend_error(r) + return flask.jsonify(r.json()), r.status_code diff --git a/talerblog/blog/content.py b/talerblog/blog/content.py new file mode 100644 index 0000000..1c9e98d --- /dev/null +++ b/talerblog/blog/content.py @@ -0,0 +1,131 @@ +# This file is part of GNU TALER. +# Copyright (C) 2014-2016 INRIA +# +# 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 + +""" +Define content and associated metadata that is served on the blog. +""" + +from collections import OrderedDict +from bs4 import BeautifulSoup +from pkg_resources import resource_stream, resource_filename +from collections import namedtuple +import logging +import os +import re + +logger = logging.getLogger(__name__) + +Article = namedtuple("Article", "slug title teaser main_file extra_files") + +articles = OrderedDict() + + +def add_article(slug, title, teaser, main_file, extra_files=[]): + articles[slug] = Article(slug, title, teaser, main_file, extra_files) + + +def get_image_file(image): + f = resource_filename("talerfrontends", os.path.join("blog/data/", image)) + return os.path.abspath(f) + + +def get_article_file(article): + f = resource_filename("talerfrontends", article.main_file) + return os.path.basename(f) + + +def add_from_html(resource_name, teaser_paragraph=0, title=None): + """ + Extract information from article html. + """ + res = resource_stream("talerfrontends", resource_name) + soup = BeautifulSoup(res, 'html.parser') + if title is None: + title_el = soup.find("h1", attrs={"class":["chapter", "unnumbered"]}) + if title_el is None: + logger.warn("Can't extract title from '%s'", resource_name) + title = resource_name + else: + title = title_el.get_text().strip() + slug = title.replace(" ", "_") + paragraphs = soup.find_all("p") + + teaser = soup.find("p", attrs={"id":["teaser"]}) + if teaser is None: + teaser = str(paragraphs[teaser_paragraph]) + p = re.compile("^/essay/[^/]+/data/[^/]+$") + imgs = soup.find_all("img") + extra_files = [] + for img in imgs: + # We require that any image whose access is regulated is src'd + # as "/data/img.png". We also need to check if the + # component actually matches the article's slug + if p.match(img['src']): + if img['src'].split(os.sep)[2] == slug: + logger.info("extra file for %s is %s" % (slug, os.path.basename(img['src']))) + extra_files.append(os.path.basename(img['src'])) + else: + logger.warning("Image src and slug don't match: '%s' != '%s'" % (img['src'].split(os.sep)[2], slug)) + add_article(slug, title, teaser, resource_name, extra_files) + + +add_from_html("blog/articles/scrap1_U.0.html", 0) +add_from_html("blog/articles/scrap1_U.1.html", 0) +add_from_html("blog/articles/scrap1_1.html", 1) +add_from_html("blog/articles/scrap1_2.html") +add_from_html("blog/articles/scrap1_3.html") +add_from_html("blog/articles/scrap1_4.html") +add_from_html("blog/articles/scrap1_5.html") +add_from_html("blog/articles/scrap1_6.html") +add_from_html("blog/articles/scrap1_7.html") +add_from_html("blog/articles/scrap1_8.html") +add_from_html("blog/articles/scrap1_9.html") +add_from_html("blog/articles/scrap1_10.html") +add_from_html("blog/articles/scrap1_11.html") +add_from_html("blog/articles/scrap1_12.html") +add_from_html("blog/articles/scrap1_13.html", 1) +add_from_html("blog/articles/scrap1_14.html") +add_from_html("blog/articles/scrap1_15.html") +add_from_html("blog/articles/scrap1_16.html") +add_from_html("blog/articles/scrap1_17.html") +add_from_html("blog/articles/scrap1_18.html") +add_from_html("blog/articles/scrap1_19.html") +add_from_html("blog/articles/scrap1_20.html", 1) +add_from_html("blog/articles/scrap1_21.html") +add_from_html("blog/articles/scrap1_22.html") +add_from_html("blog/articles/scrap1_23.html") +add_from_html("blog/articles/scrap1_24.html") +add_from_html("blog/articles/scrap1_25.html", 1) +add_from_html("blog/articles/scrap1_26.html", 1) +add_from_html("blog/articles/scrap1_27.html") +add_from_html("blog/articles/scrap1_28.html", 1) +add_from_html("blog/articles/scrap1_29.html") +add_from_html("blog/articles/scrap1_30.html", 1) +add_from_html("blog/articles/scrap1_31.html", 1) +add_from_html("blog/articles/scrap1_32.html") +add_from_html("blog/articles/scrap1_33.html") +add_from_html("blog/articles/scrap1_34.html") +add_from_html("blog/articles/scrap1_35.html") +add_from_html("blog/articles/scrap1_36.html") +add_from_html("blog/articles/scrap1_37.html") +add_from_html("blog/articles/scrap1_38.html") +add_from_html("blog/articles/scrap1_39.html") +add_from_html("blog/articles/scrap1_40.html") +add_from_html("blog/articles/scrap1_41.html") +add_from_html("blog/articles/scrap1_42.html") +add_from_html("blog/articles/scrap1_43.html", 2) +add_from_html("blog/articles/scrap1_46.html", 1) +add_from_html("blog/articles/scrap1_47.html") -- cgit v1.2.3