taler-merchant-demos

Python-based Frontends for the Demonstration Web site
Log | Files | Refs | Submodules | README | LICENSE

commit 836fa32d29ecdbe13d7eb703c3fc02a34dec7882
parent 8c32ddc42e1d71110c2582b644bfeadf2653ad99
Author: Özgür Kesim <oec@codeblau.de>
Date:   Thu,  3 Oct 2024 17:35:19 +0200

[provision] initial work on self-provisioning

Diffstat:
Mpyproject.toml | 6+++++-
Mtalermerchantdemos/cli.py | 6+++---
Atalermerchantdemos/provision/__init__.py | 3+++
Atalermerchantdemos/provision/provision.py | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atalermerchantdemos/static/provision.css | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Atalermerchantdemos/templates/provision-base.html.j2 | 17+++++++++++++++++
Atalermerchantdemos/templates/provision-done.html.j2 | 40++++++++++++++++++++++++++++++++++++++++
Atalermerchantdemos/templates/provision-error.html.j2 | 24++++++++++++++++++++++++
Atalermerchantdemos/templates/provision-index.html.j2 | 40++++++++++++++++++++++++++++++++++++++++
9 files changed, 454 insertions(+), 4 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml @@ -1,11 +1,12 @@ [tool.poetry] name = "talermerchantdemos" -version = "0.8.2" +version = "0.9.0" description = "Taler merchant demos" authors = [ "Florian Dold <dold@taler.net>", "Marcello Stanisci <ms@taler.net>", "Christian Grothoff <grothoff@taler.net>", + "Özgür Kesim <oec-taler@kesim.org>", ] license = "AGPL3+" include = [ @@ -27,6 +28,9 @@ include = [ # Donation files "talermerchantdemos/donations/templates/*.j2", "talermerchantdemos/donations/static/*.css", + # Provision files + "talermerchantdemos/provision/templates/*.j2", + "talermerchantdemos/provision/static/*.css", ] diff --git a/talermerchantdemos/cli.py b/talermerchantdemos/cli.py @@ -99,10 +99,10 @@ class StandaloneApplication(gunicorn.app.base.BaseApplication): ) @click.argument("which-shop") def demos(config, http_port, which_shop): - """WHICH_SHOP is one of: blog, donations or landing.""" + """WHICH_SHOP is one of: blog, donations, provision, or landing.""" - if which_shop not in ["blog", "donations", "landing"]: - print("Please use a valid shop name: blog, donations, landing.") + if which_shop not in ["blog", "donations", "provision", "landing"]: + print("Please use a valid shop name: blog, donations, provision, landing.") sys.exit(1) options = { diff --git a/talermerchantdemos/provision/__init__.py b/talermerchantdemos/provision/__init__.py @@ -0,0 +1,3 @@ +from talermerchantdemos.provision.provision import app + +__all__ = ["app"] diff --git a/talermerchantdemos/provision/provision.py b/talermerchantdemos/provision/provision.py @@ -0,0 +1,273 @@ +## +# This file is part of GNU TALER. +# Copyright (C) 2024 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 <http://www.gnu.org/licenses/> +# +# @author Özgür Kesim +# @brief Implementation of a merchant self-provision service + +import base64 +import logging +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 os +import time +from datetime import datetime +import traceback +import re +import hashlib +import struct +import subprocess +import urllib +from ..util.talerconfig import TalerConfig, ConfigurationError +from urllib.parse import urljoin +from ..httpcommon import backend_post, backend_get, make_utility_processor, get_locale +import sys + +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) + +logging.basicConfig() +LOGGER = logging.getLogger(__name__) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +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") +app.config.from_object(__name__) + +BABEL_TRANSLATION_DIRECTORIES = "../translations" +babel = Babel(app) +babel.localeselector(get_locale) + +config_filename = uwsgi.opt["config_filename"].decode("utf-8") +if config_filename == "": + config_filename = None +config = TalerConfig.from_file(config_filename) + +CURRENCY = config["taler"]["currency"].value_string(required=True) + +backend_urls = {} +backend_apikeys = {} +timeouts={} + +timeouts["init"]= config["frontend-demo-provision"][f"timeout_init"].value_string(required=True) +timeouts["idle"]= config["frontend-demo-provision"][f"timeout_idle"].value_string(required=True) + +def add_backend(name): + backend_urls[name] = config["frontend-demo-provision"][f"backend_url_{name}"].value_string(required=True) + backend_apikeys[name] = config["frontend-demo-provision"][f"backend_apikey_{name}"].value_string(required=True) + +add_backend("merchant") +add_backend("bank") + + + +LOGGER.info("Using translations from:" + ":".join(list(babel.translation_directories))) +LOGGER.info("currency: " + CURRENCY) +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( + "provision", os.environ.get("TALER_ENV_URL_MERCHANT_PROVISION") + ) +) + + +## +# 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. +def err_abort(abort_status_code, **params): + t = flask.render_template("provision-error.html.j2", **params) + flask.abort(flask.make_response(t, abort_status_code)) + + +## +# Issue a GET request to the backend. +# +# @param endpoint the backend endpoint where to issue the request. +# @param params (dict type of) URL parameters to append to the request. +# @return the JSON response from the backend, or a error response +# if something unexpected happens. +def backend_instanced_get(instance, endpoint, params): + return backend_get(backend_urls[instance], endpoint, params, auth_token=backend_apikeys[instance]) + + +## +# POST a request to the backend, and return a error +# response if any error occurs. +# +# @param endpoint the backend endpoint where to POST +# this request. +# @param json the POST's body. +# @return the backend response (JSON format). +def backend_instanced_post(instance, endpoint, json): + return backend_post(backend_urls[instance], endpoint, json, auth_token=backend_apikeys[instance]) + + +## +# "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): + t = flask.render_template( + "provision-error.html.j2", + page_title=gettext("GNU Taler Demo: Error"), + message=str(e), + ) + flask.abort(flask.make_response(t, 500)) + +## +# 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, redirecting to /<lang>/ +# +# @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 main index page. +# +# @return response object of the index page. +@app.route("/<lang>/") +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=f"Language {lang} not found", + ) + + return flask.render_template( + "provision-index.html.j2", + page_title=gettext("GNU Taler Demo: Provision"), + merchant_currency=CURRENCY, + merchant_url=backend_urls["merchant"], + bank_url=backend_urls["bank"], + ) + + +# Acceptable merchant names must match this regular expression +allowed = re.compile("^[a-zA-Z]([a-zA-Z0-9_. -]+)[a-zA-Z0-9][.]?$") + +## +# Handle the "/provision" request. +# +# @return response object for the /provision page. +@app.route("/<lang>/provision", methods=["POST"]) +def provision(lang): + fullname = flask.request.form.get("fullname") + fullname = fullname.strip(' \t\n\r') + if not fullname: + return err_abort(400, message=gettext("Full name required.")) + if not allowed.match(fullname): + return err_abort(400, message=gettext("Full name not acceptable.")) + + # Only create an merchant with the same name every 15 minute + n = datetime.now() + ts = datetime(n.year, n.month, n.day, n.hour, n.minute % 15).timestamp() + + m = hashlib.sha256() + m.update(fullname.encode('utf-8')) + m.update(struct.pack('d', ts)) + hash = m.hexdigest() + + merchant_id = "merchant-"+hash[:8] + access_token = hash[8:20] + + ret = subprocess.run(["taler-harness", + "deployment", + "provision-bank-and-merchant", + "--legal-name='{n}'".format(n=fullname), + "--id='{id}'".format(id=merchant_id), + "--password='{pw}'".format(pw=access_token), + "--merchant-management-token='{t}'".format(t=backend_apikeys["merchant"]), + "--bank-admin-token='{t}'".format(t=backend_apikeys["bank"]), + backend_urls["merchant"], + backend_urls["bank"], + ], capture_output=True) + + if ret.returncode != 0: + LOGGER.error("taler-harness returned {d},\nstdout:>>>>{o}<<<<\nstderr:>>>>{e}<<<<<\n" + .format(d=ret.returncode,o=ret.stdout.decode(),e=ret.stderr.decode())) + return internal_error("Internal error, couldn't create instance. Soooo sorry! 🤷") + + + LOGGER.debug("taler-harness output:>>>>{o}<<<<)".format(o=ret.stdout.decode())) + LOGGER.info("merchant instance {id} created with hash: {hash}".format(id=merchant_id,hash=hash)) + return flask.render_template( + "provision-done.html.j2", + page_title=gettext("GNU Taler Demo: Self-Provision"), + fullname=fullname, + merchant_id=merchant_id, + access_token=access_token, + bank_url=backend_urls["bank"], + merchant_url=backend_urls["merchant"], + timeout_init=timeouts["init"], + timeout_idle=timeouts["idle"], + ) + + +@app.errorhandler(404) +def handler(e): + return flask.render_template( + "provision-error.html.j2", + page_title=gettext("GNU Taler Demo: Provision Error"), + message=gettext("Page not found"), + ) diff --git a/talermerchantdemos/static/provision.css b/talermerchantdemos/static/provision.css @@ -0,0 +1,49 @@ +@import url(/static/theme.css); +nav, +nav a, +nav span, +.navcontainer, +.demobar, +nav button, +.navbtn { + color: white; + background: DarkSlateGray; +} + +nav a.active, +nav span.active, +.navbtn.active { + background-color: LightSlateGray; +} + +nav a.active:hover, +nav span.active:hover, +.navbtn.active:hover, +nav a:hover, +nav button:hover, +nav span:hover, +.navbtn:hover { + background: SlateGray; +} + +form { + padding: 1em; + background: SlateGray; + border-radius: 5px; + font-size: large; +} + +form label { + color: white; +} + +table td,th { + padding: 0.25em; +} +table th { + text-align: right; +} + +ol, ul { + line-height: 1.5em; +} diff --git a/talermerchantdemos/templates/provision-base.html.j2 b/talermerchantdemos/templates/provision-base.html.j2 @@ -0,0 +1,17 @@ +{% extends "common-base.html.j2" %} + +{% block head %} + <link rel="stylesheet" type="text/css" href="{{ static('provision.css') }}" /> +{% endblock head %} + + +{% block header_content %} + +<h1> +<span class="it"><a href="{{ env('TALER_ENV_URL_MERCHANT_PROVISION', '#') }}">{{gettext("Self-Provision")}}</a></span></h1> +<p>{{ +gettext ("This is the self-provision page for merchant instances on our demo site.") +}} +</p> + +{% endblock %} diff --git a/talermerchantdemos/templates/provision-done.html.j2 b/talermerchantdemos/templates/provision-done.html.j2 @@ -0,0 +1,40 @@ +{% extends "provision-base.html.j2" %} + +{% block main %} + <h2>{{ gettext("Your merchant demo instance has been created!") }}</h2> + <p> + {{ gettext("Please write this information down:") }} + <table> + <tr> + <th>{{ gettext("merchant full name") }}:</th> + <td>{{fullname}}</td> + </tr> + <tr> + <th>{{ gettext("merchant-id") }}:</th> + <td>{{merchant_id}}</td> + </tr> + <tr> + <th>{{ gettext("access-token") }}:</th> + <td>{{access_token}}</td> + </tr> + </table> + </p> + <p> + {{ gettext("With the merchant-id and access-token, you can now") }} + <ul> + <li>{{ gettext("Login to the <a href='{url}/instances/{id}/webui/#/inventory'>your merchant's backend</a> instance for administration.").format(url=merchant_url,id=merchant_id) }}</li> + <li>{{ gettext("Create orders via the API at <tt>{url}/instances/{id}/private/orders</tt>, see <a href='https://docs.taler.net/taler-merchant-api-tutorial.html#merchant-payment-processing'>the documentation for details</a>.").format(url=merchant_url,id=merchant_id) }}</li> + <li>{{ gettext("Login to the <a href='{url}'>demo bank</a> as merchant to see incoming wire transfers from the exchange.").format(url=bank_url) }}</li> + </ul> + </p> + <p> + {{ gettext("<b>Note:</b> the merchant instance will be automatically removed when") }} + <ol> + <li>{{ gettext("no order has been created within {timeout} <i>right after</i> creation, or").format(timeout=timeout_init) }} </li> + <li>{{ gettext("no order has been created for {timeout}.".format(timeout=timeout_idle)) }}</li> + </ol> + </p> + + <h3>Happy Hacking!</h3> + +{% endblock main %} diff --git a/talermerchantdemos/templates/provision-error.html.j2 b/talermerchantdemos/templates/provision-error.html.j2 @@ -0,0 +1,24 @@ +{% extends "provision-base.html.j2" %} +{% block main %} + <h2>{{ gettext("Error encountered during provisioning") }}</h2> + + <p>{{ message }}</p> + + {% if status_code %} + <p> + {{ gettext ("The backend returned status code {code}.").format(code=status_code) }}. + </p> + {% endif %} + + {% if json %} + <p>{{gettext("Backend response:")}}</p> + <pre>{{ json }}</pre> + {% endif %} + + {% if stack %} + <p>{{gettext("Stack trace:")}}</p> + <pre> + {{ stack }} + </pre> + {% endif %} +{% endblock main %} diff --git a/talermerchantdemos/templates/provision-index.html.j2 b/talermerchantdemos/templates/provision-index.html.j2 @@ -0,0 +1,40 @@ +{% extends "provision-base.html.j2" %} + +{% block main %} +<h2>{{ gettext("Provision yourself a demo merchant instance!") }}</h2> + +<p> +{{ + gettext("If you are a frontend developer and just want to integrate payments with Taler into your UI, simply provision yourself here a merchant instance and a corresponding bank account in the demo-environment, and start using the merchant API on this demo site!") +}} +</p> + +<p> +{{ gettext("On success you will receive a <b>merchant-ID</b> and an <b>access-token</b>. You need that information") }} +<ul> + <li>{{ gettext("to create orders via your merchant's own backend API and make changes in the merchant's inventory at our demo <a href='{backend}'>merchant backend</a>, and").format(backend=backend_url) }}</li> + <li>{{ gettext("to use the merchant's bank account at our <a href='{bank}'>demo bank</a> and see the incoming wire transfers from the payment service provider.").format(bank=bank_url) }}</li> +</ul> + +</p> + +<div> + <form action="{{ url_for('provision',lang=getlang()) }}" method="post" class="pure-form"> + <div class="form"> + <div> + <label for="fullname"> + {{ gettext("Enter the full name for your merchant:") }} + </label> + <input name="fullname" id="fullname" type="text" /> + + <button class="pure-button pure-button-primary" name="submit"> + {{gettext("Provision!")}} + </button> + </div> + </div> + </form> +</div> + + + +{% endblock %}