diff options
author | Florian Dold <florian.dold@gmail.com> | 2019-08-27 03:56:54 +0200 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2019-08-28 21:28:59 +0200 |
commit | 97835ef689b538cb3e4bee294bd0fb2b3f0a9df2 (patch) | |
tree | f0ef076bfcc45285a818c75faf07a2351d20cb6e | |
parent | 11a193449e291c240f3cac96fe21e6c21a2a2649 (diff) | |
download | bank-97835ef689b538cb3e4bee294bd0fb2b3f0a9df2.tar.gz bank-97835ef689b538cb3e4bee294bd0fb2b3f0a9df2.tar.bz2 bank-97835ef689b538cb3e4bee294bd0fb2b3f0a9df2.zip |
implement new withdraw API and support taler://withdraw
-rw-r--r-- | .style.yapf | 5 | ||||
-rwxr-xr-x | setup.py | 2 | ||||
-rw-r--r-- | talerbank/app/amount.py | 27 | ||||
-rw-r--r-- | talerbank/app/migrations/0001_initial.py (renamed from talerbank/app/migrations/0001_squashed_0013_remove_banktransaction_reimburses.py) | 47 | ||||
-rw-r--r-- | talerbank/app/models.py | 14 | ||||
-rw-r--r-- | talerbank/app/templates/profile_page.html | 18 | ||||
-rw-r--r-- | talerbank/app/templates/withdraw_confirm.html (renamed from talerbank/app/templates/pin_tan.html) | 7 | ||||
-rw-r--r-- | talerbank/app/templates/withdraw_show.html | 76 | ||||
-rw-r--r-- | talerbank/app/tests.py | 2 | ||||
-rw-r--r-- | talerbank/app/urls.py | 80 | ||||
-rw-r--r-- | talerbank/app/views.py | 225 | ||||
-rw-r--r-- | talerbank/jinja2.py | 13 | ||||
-rw-r--r-- | talerbank/settings.py | 4 | ||||
-rw-r--r-- | talerbank/urls.py | 3 |
14 files changed, 318 insertions, 205 deletions
diff --git a/.style.yapf b/.style.yapf new file mode 100644 index 0000000..3b39780 --- /dev/null +++ b/.style.yapf @@ -0,0 +1,5 @@ +[style] +based_on_style = pep8 +coalesce_brackets=True +column_limit=80 +dedent_closing_brackets=True @@ -17,6 +17,8 @@ setup(name='talerbank', "mock", "jinja2", "pylint", + "qrcode", + "lxml", "django-lint"], scripts=["./bin/taler-bank-manage"], package_data={ diff --git a/talerbank/app/amount.py b/talerbank/app/amount.py index 83f91e0..2a04013 100644 --- a/talerbank/app/amount.py +++ b/talerbank/app/amount.py @@ -224,25 +224,26 @@ class Amount: self.fraction -= amount.fraction ## - # Dump string from this amount, will put 'ndigits' numbers - # after the dot. + # Convert the amount to a string. # # @param self this object. # @param ndigits how many digits we want for the fractional part. # @param pretty if True, put the currency in the last position and # omit the colon. - def stringify(self, ndigits: int, pretty=False) -> str: - if ndigits <= 0: - raise BadFormatAmount("ndigits must be > 0") - tmp = self.fraction - fraction_str = "" - while ndigits > 0: - fraction_str += str(int(tmp / (Amount._fraction() / 10))) - tmp = (tmp * 10) % (Amount._fraction()) - ndigits -= 1 + def stringify(self, ndigits=0, pretty=False) -> str: + s = str(self.value) + if self.fraction != 0: + s += "." + frac = self.fraction + while frac != 0 or ndigits != 0: + s += str(int(frac / (Amount._fraction() / 10))) + frac = (frac * 10) % (Amount._fraction()) + ndigits -= 1 + elif ndigits != 0: + s += "." + ("0" * ndigits) if not pretty: - return "%s:%d.%s" % (self.currency, self.value, fraction_str) - return "%d.%s %s" % (self.value, fraction_str, self.currency) + return f"{self.currency}:{s}" + return f"{s} {self.currency}" ## # Dump the Taler-compliant 'dict' amount from diff --git a/talerbank/app/migrations/0001_squashed_0013_remove_banktransaction_reimburses.py b/talerbank/app/migrations/0001_initial.py index 31c38a3..8fa3da0 100644 --- a/talerbank/app/migrations/0001_squashed_0013_remove_banktransaction_reimburses.py +++ b/talerbank/app/migrations/0001_initial.py @@ -1,15 +1,14 @@ -# Generated by Django 2.0.1 on 2018-02-13 10:23 +# Generated by Django 2.2.4 on 2019-08-27 18:55 from django.conf import settings from django.db import migrations, models import django.db.models.deletion import talerbank.app.models +import uuid class Migration(migrations.Migration): - replaces = [('app', '0001_initial'), ('app', '0002_bankaccount_amount'), ('app', '0003_auto_20171030_1346'), ('app', '0004_auto_20171030_1428'), ('app', '0005_remove_banktransaction_currency'), ('app', '0006_auto_20171031_0823'), ('app', '0007_auto_20171031_0906'), ('app', '0008_auto_20171031_0938'), ('app', '0009_auto_20171120_1642'), ('app', '0010_banktransaction_cancelled'), ('app', '0011_banktransaction_reimburses'), ('app', '0012_auto_20171212_1540'), ('app', '0013_remove_banktransaction_reimburses')] - initial = True dependencies = [ @@ -22,45 +21,33 @@ class Migration(migrations.Migration): fields=[ ('is_public', models.BooleanField(default=False)), ('debit', models.BooleanField(default=False)), - ('balance_value', models.IntegerField(default=0)), - ('balance_fraction', models.IntegerField(default=0)), - ('balance', models.FloatField(default=0)), - ('currency', models.CharField(default='', max_length=12)), ('account_no', models.AutoField(primary_key=True, serialize=False)), + ('amount', talerbank.app.models.AmountField(default=talerbank.app.models.get_zero_amount)), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), migrations.CreateModel( + name='TalerWithdrawOperation', + fields=[ + ('withdraw_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('amount', talerbank.app.models.AmountField(default=False)), + ('selection_done', models.BooleanField(default=False)), + ('withdraw_done', models.BooleanField(default=False)), + ('selected_reserve_pub', models.TextField(null=True)), + ('selected_exchange_account', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='selected_exchange_account', to='app.BankAccount')), + ('withdraw_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='withdraw_account', to='app.BankAccount')), + ], + ), + migrations.CreateModel( name='BankTransaction', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', talerbank.app.models.AmountField(default=False)), ('subject', models.CharField(default='(no subject given)', max_length=200)), ('date', models.DateTimeField(auto_now=True, db_index=True)), + ('cancelled', models.BooleanField(default=False)), ('credit_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credit_account', to='app.BankAccount')), ('debit_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='debit_account', to='app.BankAccount')), - ('amount', talerbank.app.models.AmountField(default=False)), - ('cancelled', models.BooleanField(default=False)), ], ), - migrations.AddField( - model_name='bankaccount', - name='amount', - field=talerbank.app.models.AmountField(default=talerbank.app.models.get_zero_amount), - ), - migrations.RemoveField( - model_name='bankaccount', - name='balance', - ), - migrations.RemoveField( - model_name='bankaccount', - name='balance_fraction', - ), - migrations.RemoveField( - model_name='bankaccount', - name='balance_value', - ), - migrations.RemoveField( - model_name='bankaccount', - name='currency', - ), ] diff --git a/talerbank/app/models.py b/talerbank/app/models.py index d7340a0..7c7717d 100644 --- a/talerbank/app/models.py +++ b/talerbank/app/models.py @@ -19,7 +19,6 @@ # @author Florian Dold import uuid -from __future__ import unicode_literals from typing import Any, Tuple from django.contrib.auth.models import User from django.db import models @@ -152,12 +151,19 @@ class BankTransaction(models.Model): cancelled = models.BooleanField(default=False) -class ApprovedWithdrawOperation(models.Model): +class TalerWithdrawOperation(models.Model): + withdraw_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) amount = AmountField(default=False) withdraw_account = models.ForeignKey( BankAccount, on_delete=models.CASCADE, db_index=True, related_name="withdraw_account") - finished = models.BooleanField(default=False) - withdraw_uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + selection_done = models.BooleanField(default=False) + withdraw_done = models.BooleanField(default=False) + selected_exchange_account = models.ForeignKey( + BankAccount, + null=True, + on_delete=models.CASCADE, + related_name="selected_exchange_account") + selected_reserve_pub = models.TextField(null=True) diff --git a/talerbank/app/templates/profile_page.html b/talerbank/app/templates/profile_page.html index abbb784..61fcd61 100644 --- a/talerbank/app/templates/profile_page.html +++ b/talerbank/app/templates/profile_page.html @@ -17,18 +17,6 @@ @author Marcello Stanisci #} -{% block head %} - <meta name="currency" value="{{ currency }}"> - <meta name="precision" value="{{ precision }}"> - <meta name="callback-url" value="{{ url('pin-question') }}"> - {% if withdraw and withdraw == "success" %} - <meta name="reserve-pub" value="{{ reserve_pub }}"> - {% endif %} - {% if suggested_exchange %} - <meta name="suggested-exchange" value="{{ suggested_exchange }}"> - {% endif %} - <link rel="stylesheet" type="text/css" href="{{ static('disabled-button.css') }}"> -{% endblock head %} {% block headermsg %} <div> <h1 class="nav">Welcome <em>{{ name }}</em>!</h1> @@ -64,11 +52,11 @@ </article> <article> <div> - <h2>Withdraw digital coins using Taler</h2> + <h2>Withdraw Money into a Taler wallet</h2> <form id="reserve-form" class="pure-form" - action="{{ url('withdraw-nojs') }}" + action="{{ url('start-withdrawal') }}" method="post" name="tform"> <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}" /> @@ -84,7 +72,7 @@ <input id="select-exchange" class="pure-button pure-button-primary" type="submit" - value="Select exchange provider" /> + value="Start withdrawal" /> </form> <h2>Wire transfer</h2> <form id="wt-form" diff --git a/talerbank/app/templates/pin_tan.html b/talerbank/app/templates/withdraw_confirm.html index 746f889..594d5a5 100644 --- a/talerbank/app/templates/pin_tan.html +++ b/talerbank/app/templates/withdraw_confirm.html @@ -20,7 +20,7 @@ {% extends "base.html" %} {% block headermsg %} - <h1 class="nav">PIN/TAN: Confirm transaction</h1> + <h1 class="nav">Confirm Withdrawal</h1> {% endblock %} {% block content %} @@ -31,15 +31,14 @@ {% endif %} <p> {{ settings_value("TALER_CURRENCY") }} Bank needs to verify that you - intend to withdraw <b>{{ amount }}</b> from - <b>{{ exchange }}</b>. + intend to withdraw <b>{{ amount }}</b> from <b>{{ exchange }}</b>. To prove that you are the account owner, please answer the following "security question" (*): </p> <p> What is {{ question }} ? </p> - <form method="post" action="{{ url('pin-verify') }}" class="pure-form"> + <form method="post" action="{{ url('withdraw-confirm', withdraw_id) }}" class="pure-form"> <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}" /> <input type="text" name="pin_0" value="" autocomplete="off" autofocus /> <input type="hidden" name="pin_1" value="{{ hashed_answer }}" /> diff --git a/talerbank/app/templates/withdraw_show.html b/talerbank/app/templates/withdraw_show.html new file mode 100644 index 0000000..3c66732 --- /dev/null +++ b/talerbank/app/templates/withdraw_show.html @@ -0,0 +1,76 @@ +<!-- + This file is part of GNU Taler. + Copyright (C) 2019 GNUnet e.V. + + GNU 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. + + GNU 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 Florian Dold +--> + +{% extends "base.html" %} + +{% block headermsg %} +<h1 class="nav">Withdraw to a Taler Wallet</h1> +{% endblock %} + +{% block head %} +<script> + // prettier-ignore + let checkUrl = JSON.parse('{{ withdraw_check_url | tojson }}'); + let delayMs = 500; + function check() { + let req = new XMLHttpRequest(); + req.onreadystatechange = function () { + if (req.readyState === XMLHttpRequest.DONE) { + if (req.status === 200) { + try { + let resp = JSON.parse(req.responseText); + if (resp.selection_done) { + document.location.reload(true); + } + } catch (e) { + console.error("could not parse response:", e); + } + } + setTimeout(check, delayMs); + } + }; + req.onerror = function () { + setTimeout(check, delayMs); + } + req.open("GET", checkUrl); + req.send(); + } + + setTimeout(check, delayMs); + +</script> +{% endblock head %} + +{% block content %} +<div class="taler-installed-hide"> + <p> + Looks like your browser doesn't support GNU Taler payments. You can try + installing a <a href="https://taler.net/en/wallet.html">wallet browser extension</a>. + </p> +</div> + +<p> + You can use this QR code to withdraw to your mobile wallet: +</p> +{{ qrcode_svg | safe }} +<p> + Click <a href="{{ taler_withdraw_uri }}">this link</a> to open your system's Taler wallet if it exists. +</p> + + +{% endblock content %}
\ No newline at end of file diff --git a/talerbank/app/tests.py b/talerbank/app/tests.py index 06e4af4..50f1dd4 100644 --- a/talerbank/app/tests.py +++ b/talerbank/app/tests.py @@ -116,7 +116,7 @@ class WithdrawTestCase(TestCase): mocked_time.return_value = 0 response = self.client.post( - reverse("pin-verify", urlconf=urls), + reverse("withdraw-confirm", urlconf=urls), {"pin_1": "0"}) args, kwargs = mocked_wire_transfer.call_args diff --git a/talerbank/app/urls.py b/talerbank/app/urls.py index d9b4491..7f2006b 100644 --- a/talerbank/app/urls.py +++ b/talerbank/app/urls.py @@ -16,42 +16,64 @@ # <http://www.gnu.org/licenses/>. # # @author Marcello Stanisci +# @author Florian Dold -from django.conf.urls import include, url +from django.urls import include, path from django.views.generic.base import RedirectView from django.contrib.auth import views as auth_views from . import views urlpatterns = [ - url(r'^', include('talerbank.urls')), - url(r'^$', RedirectView.as_view(pattern_name="profile"), - name="index"), - url(r'^favicon\.ico$', views.ignore), - url(r'^admin/add/incoming$', views.add_incoming, - name="add-incoming"), - url(r'^login/$', + path("", RedirectView.as_view(pattern_name="profile"), name="index"), + path("favicon.ico", views.ignore), + path("admin/add/incoming", views.add_incoming, name="add-incoming"), + path( + "login/", auth_views.LoginView.as_view( template_name="login.html", - authentication_form=views.TalerAuthenticationForm), - name="login"), - url(r'^logout/$', views.logout_view, name="logout"), - url(r'^accounts/register/$', views.register, name="register"), - url(r'^register$', views.register_headless, name="register-headless"), - url(r'^profile$', views.profile_page, name="profile"), - url(r'^history$', views.serve_history, name="history"), - url(r'^history-range$', views.serve_history_range, name="history-range"), - url(r'^reject$', views.reject, name="reject"), - url(r'^withdraw$', views.withdraw_nojs, name="withdraw-nojs"), - url(r'^taler/withdraw$', views.withdraw_headless, name="withdraw-headless"), - url(r'^public-accounts$', views.serve_public_accounts, - name="public-accounts"), - url(r'^public-accounts/(?P<name>[a-zA-Z0-9]+)$', + authentication_form=views.TalerAuthenticationForm + ), + name="login" + ), + path("logout/", views.logout_view, name="logout"), + path("accounts/register", views.register, name="register"), + path("profile", views.profile_page, name="profile"), + path("history", views.serve_history, name="history"), + path("history-range", views.serve_history_range, name="history-range"), + path("reject", views.reject, name="reject"), + path( + "api/withdraw-operation/<str:withdraw_id>", + views.api_withdraw_operation, + name="api-withdraw-operation" + ), + path( + "api/withdraw-headless", + views.withdraw_headless, + name="withdraw-headless" + ), + path("api/register", views.register_headless, name="register-headless"), + path("start-withdrawal", views.start_withdrawal, name="start-withdrawal"), + path( + "show-withdrawal/<str:withdraw_id>", + views.show_withdrawal, + name="withdraw-show" + ), + path( + "confirm-withdrawal/<str:withdraw_id>", + views.confirm_withdrawal, + name="withdraw-confirm" + ), + path( + "public-accounts", views.serve_public_accounts, name="public-accounts" + ), + path( + "public-accounts/<str:name>", views.serve_public_accounts, - name="public-accounts"), - url(r'^public-accounts/(?P<name>[a-zA-Z0-9]+)/(?P<page>[0-9]+)$', + name="public-accounts" + ), + path( + "public-accounts/<str:name>/<int:page>", views.serve_public_accounts, - name="public-accounts"), - url(r'^pin/question$', views.pin_tan_question, - name="pin-question"), - url(r'^pin/verify$', views.pin_tan_verify, name="pin-verify"), - ] + name="public-accounts" + ), +] diff --git a/talerbank/app/views.py b/talerbank/app/views.py index d035049..56c6993 100644 --- a/talerbank/app/views.py +++ b/talerbank/app/views.py @@ -41,11 +41,14 @@ from django.contrib.auth.models import User from django.db.models import Q from django.http import JsonResponse, HttpResponse from django.shortcuts import render, redirect +from django.core.exceptions import ObjectDoesNotExist from datetime import datetime -from .models import BankAccount, BankTransaction +from .models import BankAccount, BankTransaction, TalerWithdrawOperation from .amount import Amount -from .schemas import \ - (HistoryParams, HistoryRangeParams, +import qrcode +import qrcode.image.svg +import lxml +from .schemas import (HistoryParams, HistoryRangeParams, URLParamValidationError, RejectData, AddIncomingData, JSONFieldException, PinTanParams, InvalidSession, @@ -170,6 +173,7 @@ def predefined_accounts_list(): ## # Thanks to [1], this class provides a dropdown menu that # can be used within a <select> element, in a <form>. +# [1] https://stackoverflow.com/questions/24783275/django-form-with-choices-but-also-with-freetext-option class InputDatalist(forms.TextInput): ## @@ -202,6 +206,7 @@ class InputDatalist(forms.TextInput): # @param renderer render engine (left as None, typically); it # is a class that respects the low-level render API from # Django, see [2] + # [2] https://docs.djangoproject.com/en/2.1/ref/forms/renderers/#low-level-widget-render-api def render(self, name, value, attrs=None, renderer=None): html = super().render( name, value, attrs=attrs, renderer=renderer) @@ -319,6 +324,7 @@ def make_question(): question = "{} {} {}".format(num1, operand, num2) return question, hash_answer(answer) + def get_acct_from_payto(uri_str: str) -> int: wire_uri = urlparse(uri_str) if wire_uri.scheme != "payto": @@ -327,87 +333,6 @@ def get_acct_from_payto(uri_str: str) -> int: ## -# This method build the page containing the math CAPTCHA that -# protects coins withdrawal. It takes all the values from the -# URL and puts them into the state, for further processing after -# a successful answer from the user. -# -# @param request Django-specific HTTP request object -# @return Django-specific HTTP response object -@require_GET -@login_required -def pin_tan_question(request): - - get_params = PinTanParams(request.GET.dict()) - if not get_params.is_valid(): - raise URLParamValidationError(get_params.errors, 400) - - - user_account = BankAccount.objects.get(user=request.user) - wire_details = get_params.cleaned_data["exchange_wire_details"] - - request.session["exchange_account_number"] = \ - get_acct_from_payto(wire_details) - amount = Amount(get_params.cleaned_data["amount_currency"], - get_params.cleaned_data["amount_value"], - get_params.cleaned_data["amount_fraction"]) - request.session["amount"] = amount.dump() - request.session["reserve_pub"] = \ - get_params.cleaned_data["reserve_pub"] - - fail_message, success_message, hint = get_session_hint( - request, - "captcha_failed") - - question, hashed_answer = make_question() - context = dict( - question=question, - hashed_answer=hashed_answer, - amount=amount.stringify(settings.TALER_DIGITS), - exchange=get_params.cleaned_data["exchange"], - fail_message=fail_message, - success_message=success_message, - hint=hint) - return render(request, "pin_tan.html", context) - - -## -# This method serves the user's answer to the math CAPTCHA, -# and reacts accordingly to its correctness. If correct (wrong), -# it redirects the user to the profile page showing a success -# (failure) message into the informational bar. -@require_POST -@login_required -def pin_tan_verify(request): - hashed_attempt = hash_answer(request.POST.get("pin_0", "")) - hashed_solution = request.POST.get("pin_1", "") - if hashed_attempt != hashed_solution: - LOGGER.warning("Wrong CAPTCHA answer: %s vs %s", - type(hashed_attempt), - type(request.POST.get("pin_1"))) - request.session["captcha_failed"] = True, False, "Wrong CAPTCHA answer." - return redirect(request.POST.get("question_url", "profile")) - # Check the session is a "pin tan" one - - if not WithdrawSessionData(request.session): - # The session is not valid: either because the client simply - # requested the page without passing through the prior step, - # or because the bank broke it in the meanwhile. Let's blame - # ourselves for now. - raise InvalidSession(503) - - amount = Amount(**request.session["amount"]) - exchange_bank_account = BankAccount.objects.get( - account_no=request.session["exchange_account_number"]) - wire_transfer(amount, - BankAccount.objects.get(user=request.user), - exchange_bank_account, - request.session["reserve_pub"]) - request.session["profile_hint"] = False, True, "Withdrawal successful!" - request.session["just_withdrawn"] = True - return redirect("profile") - -## # Class representing the registration form. class UserReg(forms.Form): username = forms.CharField() @@ -954,6 +879,52 @@ def withdraw_headless(request, user): data.cleaned_data["reserve_pub"]) return JsonResponse(ret_obj) + + +# Endpoint used by the browser and wallet to check withdraw status and +# put in the exchange info. +@csrf_exempt +def api_withdraw_operation(request, withdraw_id): + try: + op = TalerWithdrawOperation.objects.get(withdraw_id=withdraw_id) + except ObjectDoesNotExist: + return JsonResponse(dict(error="withdraw operation does not exist"), status=404) + user_acct_no = op.withdraw_account.account_no + host = request.get_host() + + if request.method == "POST": + if op.selection_done or op.withdraw_done: + return JsonResponse(dict(error="selection of withdraw parameters already done"), status=409) + data = json.loads(request.body.decode("utf-8")) + exchange_payto_uri = data.get("selected_exchange") + try: + account_no = get_acct_from_payto(exchange_payto_uri) + except: + return JsonResponse(dict(error="exchange payto URI malformed"), status=400) + try: + exchange_acct = BankAccount.objects.get(account_no=account_no) + except ObjectDoesNotExist: + return JsonResponse(dict(error="bank accound in payto URI unknown"), status=400) + op.selected_exchange_account = exchange_acct + selected_reserve_pub = data.get("reserve_pub") + if not isinstance(selected_reserve_pub, str): + return JsonResponse(dict(error="reserve_pub must be a string"), status=400) + op.selected_reserve_pub = selected_reserve_pub + op.selection_done = True + op.save() + return JsonResponse(dict(), status=200) + elif request.method == "GET": + return JsonResponse(dict( + selection_done=op.selection_done, + transfer_done=op.withdraw_done, + amount=op.amount.stringify(), + wire_types=["x-taler-bank"], + sender_wire=f"payto://x-taler-bank/{host}/{user_acct_no}", + suggested_exchange=settings.TALER_SUGGESTED_EXCHANGE, + confirm_transfer_url=request.build_absolute_uri(reverse("withdraw-confirm", args=(withdraw_id,))))) + else: + return JsonResponse(dict(error="only GET and POST are allowed"), status=305) + ## # Serve a Taler withdrawal request; takes the amount chosen @@ -964,25 +935,76 @@ def withdraw_headless(request, user): # @return Django-specific HTTP response object. @login_required @require_POST -def withdraw_nojs(request): - - amount = Amount.parse( - request.POST.get("kudos_amount", "not-given")) +def start_withdrawal(request): user_account = BankAccount.objects.get(user=request.user) - response = HttpResponse(status=202) - response["X-Taler-Operation"] = "create-reserve" - response["X-Taler-Callback-Url"] = reverse("pin-question") - response["X-Taler-Wt-Types"] = '["x-taler-bank"]' - response["X-Taler-Amount"] = json.dumps(amount.dump()) - response["X-Taler-Sender-Wire"] = "payto://x-taler-bank/{}/{}".format( - request.get_host(), - user_account.account_no, + amount = Amount.parse(request.POST.get("kudos_amount", "not-given")) + op = TalerWithdrawOperation(amount=amount, withdraw_account=user_account) + op.save() + return redirect("withdraw-show", withdraw_id=op.withdraw_id) + + +def get_qrcode_svg(data): + factory = qrcode.image.svg.SvgImage + img = qrcode.make(data, image_factory=factory) + return lxml.etree.tostring(img.get_image()).decode("utf-8") + + +@login_required +@require_GET +def show_withdrawal(request, withdraw_id): + op = TalerWithdrawOperation.objects.get(withdraw_id=withdraw_id) + if op.selection_done: + return redirect("withdraw-confirm", withdraw_id=op.withdraw_id) + host = request.get_host() + taler_withdraw_uri = f"taler://withdraw/{host}/-/{op.withdraw_id}" + qrcode_svg = get_qrcode_svg(taler_withdraw_uri) + context = dict( + taler_withdraw_uri=taler_withdraw_uri, + qrcode_svg=qrcode_svg, + withdraw_check_url=reverse("api-withdraw-operation", kwargs=dict(withdraw_id=op.withdraw_id)), ) - if settings.TALER_SUGGESTED_EXCHANGE: - response["X-Taler-Suggested-Exchange"] = \ - settings.TALER_SUGGESTED_EXCHANGE - return response + resp = render(request, "withdraw_show.html", context, status=402) + resp["Taler"] = taler_withdraw_uri + return resp + +@login_required +@require_http_methods(["GET", "POST"]) +def confirm_withdrawal(request, withdraw_id): + op = TalerWithdrawOperation.objects.get(withdraw_id=withdraw_id) + if not op.selection_done: + raise Exception("invalid state (withdrawal parameter selection not done)") + if op.withdraw_done: + return redirect("profile") + if request.method == "POST": + hashed_attempt = hash_answer(request.POST.get("pin_0", "")) + hashed_solution = request.POST.get("pin_1", "") + if hashed_attempt != hashed_solution: + LOGGER.warning("Wrong CAPTCHA answer: %s vs %s", + type(hashed_attempt), + type(request.POST.get("pin_1"))) + request.session["captcha_failed"] = True, False, "Wrong CAPTCHA answer." + return redirect("withdraw-confirm", withdraw_id=withdraw_id) + op.withdraw_done = True + op.save() + wire_transfer(op.amount, + BankAccount.objects.get(user=request.user), + op.selected_exchange_account, + op.selected_reserve_pub) + request.session["profile_hint"] = False, True, "Withdrawal successful!" + request.session["just_withdrawn"] = True + return redirect("profile") + if request.method == "GET": + question, hashed_answer = make_question() + context = dict( + question=question, + hashed_answer=hashed_answer, + withdraw_id=withdraw_id, + amount=op.amount.stringify(settings.TALER_DIGITS), + exchange=op.selected_exchange_account.user + ) + return render(request, "withdraw_confirm.html", context) + raise Exception("not reached") ## # Make a wire transfer between two accounts (internal to the bank) @@ -1051,6 +1073,3 @@ def wire_transfer(amount, debit_account, credit_account, subject): transaction_item.save() return transaction_item - -# [1] https://stackoverflow.com/questions/24783275/django-form-with-choices-but-also-with-freetext-option -# [2] https://docs.djangoproject.com/en/2.1/ref/forms/renderers/#low-level-widget-render-api diff --git a/talerbank/jinja2.py b/talerbank/jinja2.py index d6a1e2b..79a4675 100644 --- a/talerbank/jinja2.py +++ b/talerbank/jinja2.py @@ -21,6 +21,7 @@ import os import math +import json from urllib.parse import urlparse from django.urls import reverse, get_script_prefix from django.conf import settings @@ -87,11 +88,11 @@ def settings_value(name): # @param url_name URL's name as defined in urlargs.py # @param kwargs key-value list that will be appended # to the URL as the parameter=value pairs. -def url(url_name, **kwargs): +def url(url_name, *args, **kwargs): # strangely, Django's 'reverse' function # takes a named parameter 'kwargs' instead # of real kwargs. - return reverse(url_name, kwargs=kwargs) + return reverse(url_name, args=args, kwargs=kwargs) ## @@ -115,6 +116,11 @@ def is_valid_amount(amount): return True +def tojson(x): + """Convert object to json""" + return json.dumps(x) + + ## # Stringifies amount. # @@ -131,6 +137,7 @@ def environment(**options): 'settings_value': settings_value, 'env': env_get, 'is_valid_amount': is_valid_amount, - 'amount_stringify': amount_stringify + 'amount_stringify': amount_stringify, + 'tojson': tojson, }) return env diff --git a/talerbank/settings.py b/talerbank/settings.py index 44fcf70..97a18dd 100644 --- a/talerbank/settings.py +++ b/talerbank/settings.py @@ -182,8 +182,8 @@ STATICFILES_DIRS = [ os.path.join(BASE_DIR, "talerbank/app/static/web-common"), ] -STATIC_ROOT = '/tmp/talerbankstatic/' -ROOT_URLCONF = "talerbank.app.urls" +STATIC_ROOT = None +ROOT_URLCONF = "talerbank.urls" try: TALER_CURRENCY = TC.value_string( diff --git a/talerbank/urls.py b/talerbank/urls.py index 3ab39b2..26f0851 100644 --- a/talerbank/urls.py +++ b/talerbank/urls.py @@ -16,5 +16,6 @@ Including another URLconf """ from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.conf.urls import url +from .app.urls import urlpatterns as app_urlpatterns -urlpatterns = staticfiles_urlpatterns() +urlpatterns = staticfiles_urlpatterns() + app_urlpatterns |