[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[taler-taldir] 02/02: add tests
From: |
gnunet |
Subject: |
[taler-taldir] 02/02: add tests |
Date: |
Wed, 06 Jul 2022 23:35:47 +0200 |
This is an automated email from the git hooks/post-receive script.
martin-schanzenbach pushed a commit to branch master
in repository taldir.
commit f16b14fa205e9cb65ba3cbc6f3140079b504e60e
Author: Martin Schanzenbach <schanzen@gnunet.org>
AuthorDate: Wed Jul 6 23:35:42 2022 +0200
add tests
---
cmd/taldir-server/main_test.go | 86 ++++
cmd/taldir-server/taldir.go | 539 +++++++++++++++++++++
cmd/taldir-server/testdata/taldir-test.conf | 34 ++
.../testdata/templates/validation_landing.html | 17 +
4 files changed, 676 insertions(+)
diff --git a/cmd/taldir-server/main_test.go b/cmd/taldir-server/main_test.go
new file mode 100644
index 0000000..ec13af1
--- /dev/null
+++ b/cmd/taldir-server/main_test.go
@@ -0,0 +1,86 @@
+// This file is part of gnunet-go, a GNUnet-implementation in Golang.
+// Copyright (C) 2019-2022 Bernd Fix >Y<
+//
+// gnunet-go is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// gnunet-go 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
+// Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+//
+// SPDX-License-Identifier: AGPL3.0-or-later
+
+
+package main_test
+
+import (
+ "os"
+ "testing"
+ "net/http"
+ "net/http/httptest"
+ "crypto/sha512"
+ "bytes"
+ "taler.net/taldir/cmd/taldir-server"
+ "taler.net/taldir/util"
+)
+
+var t main.Taldir
+
+var validRegisterRequest = []byte(`
+ {
+ "address": "abc@test",
+ "public_key": "000G006XE97PTWV3B7AJNCRQZA6BF26HPV3XZ07293FMY7KD4181946A90",
+ "inbox_url": "myinbox@xyz",
+ "duration": 23
+ }
+`)
+
+func TestMain(m *testing.M) {
+ t.Initialize("testdata/taldir-test.conf", true)
+ code := m.Run()
+ t.ClearDatabase()
+ os.Exit(code)
+}
+
+func getHAddress(addr string) string {
+ h := sha512.New()
+ h.Write([]byte(addr))
+ return util.EncodeBinaryToString(h.Sum(nil))
+}
+
+func TestNoEntry(s *testing.T) {
+ t.ClearDatabase()
+
+ h_addr := getHAddress("jdoe@example.com")
+ req, _ := http.NewRequest("GET", "/" + h_addr, nil)
+ response := executeRequest(req)
+
+ if http.StatusNotFound != response.Code {
+ s.Errorf("Expected response code %d. Got %d\n", http.StatusNotFound,
response.Code)
+ }
+}
+
+func executeRequest(req *http.Request) *httptest.ResponseRecorder {
+ rr := httptest.NewRecorder()
+ t.Router.ServeHTTP(rr, req)
+ return rr
+}
+
+func TestRegisterRequest(s *testing.T) {
+ t.ClearDatabase()
+
+ req, _ := http.NewRequest("POST", "/register/test",
bytes.NewBuffer(validRegisterRequest))
+ response := executeRequest(req)
+
+ if http.StatusAccepted != response.Code {
+ s.Errorf("Expected response code %d. Got %d\n", http.StatusAccepted,
response.Code)
+ }
+}
+
+
diff --git a/cmd/taldir-server/taldir.go b/cmd/taldir-server/taldir.go
new file mode 100644
index 0000000..3d22853
--- /dev/null
+++ b/cmd/taldir-server/taldir.go
@@ -0,0 +1,539 @@
+// This file is part of gnunet-go, a GNUnet-implementation in Golang.
+// Copyright (C) 2019-2022 Bernd Fix >Y<
+//
+// gnunet-go is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// gnunet-go 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
+// Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+//
+// SPDX-License-Identifier: AGPL3.0-or-later
+
+
+package main
+
+/* TODO
+ - ToS API (terms, privacy) with localizations
+ - Prettify QR code landing page
+ - Base32: Use gnunet-go module? (currently copied)
+ - OrderId processing
+ - Maintenance of database: When to delete expired validations?
+*/
+
+import (
+ "os"
+ "os/exec"
+ "time"
+ "fmt"
+ "log"
+ "net/http"
+ "html/template"
+ "encoding/json"
+ "github.com/gorilla/mux"
+ "gorm.io/gorm"
+ "encoding/base64"
+ "taler.net/taldir/util"
+ "taler.net/taldir/gana"
+ "crypto/sha512"
+ "gorm.io/driver/postgres"
+ "gopkg.in/ini.v1"
+ "strings"
+ "github.com/skip2/go-qrcode"
+)
+
+type Taldir struct {
+
+ // The main router
+ Router *mux.Router
+
+ // The main DB handle
+ Db *gorm.DB
+
+ // Our configuration from the config.json
+ Cfg *ini.File
+
+ // Map of supported validators as defined in the configuration
+ Validators map[string]bool
+
+ // landing page
+ ValidationTpl *template.Template
+
+ // The address salt
+ Salt string
+
+ // Request frequency
+ RequestFrequency int64
+}
+
+type VersionResponse struct {
+ // libtool-style representation of the Merchant protocol version, see
+ //
https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ Version string `json:"version"`
+
+ // Name of the protocol.
+ Name string `json:"name"` // "taler-directory"
+
+ // Supported registration methods
+ Methods []Method `json:"methods"`
+
+ // fee for one month of registration
+ MonthlyFee string `json:"monthly_fee"`
+
+}
+
+type Method struct {
+
+ // Name of the method, e.g. "email" or "sms".
+ Name string `json:"name"`
+
+ // per challenge fee
+ ChallengeFee string `json:"challenge_fee"`
+
+}
+
+type RateLimitedResponse struct {
+
+ // Taler error code, TALER_EC_TALDIR_REGISTER_RATE_LIMITED.
+ Code int `json:"code"`
+
+ // At what frequency are new registrations allowed. FIXME: In what?
Currently: In microseconds
+ RequestFrequency int64 `json:"request_frequency"`
+
+ // The human readable error message.
+ Hint string `json:"hint"`
+}
+
+type RegisterMessage struct {
+
+ // Address, in method-specific format
+ Address string `json:"address"`
+
+ // Public key of the user to register
+ PublicKey string `json:"public_key"`
+
+ // (HTTPS) endpoint URL for the inbox service for this address
+ Inbox string `json:"inbox_url"`
+
+ // For how long should the registration last
+ Duration int64 `json:"duration"`
+
+ // Order ID, if the client recently paid for this registration
+ // FIXME: As an optional field, maybe we want to parse this separately
+ // instead?
+ // Order_id string `json:"order_id"`
+}
+
+// A mappind entry from the identity key hash to a wallet key
+// The identity key hash is sha256(sha256(identity)|salt) where identity is
+// one of the identity key types supported (e.g. email)
+type Entry struct {
+
+ // ORM
+ gorm.Model `json:"-"`
+
+ // The salted hash (SHA512) of the hashed address (h_address)
+ HsAddress string `json:"-"`
+
+ // (HTTPS) endpoint URL for the inbox service for this address
+ Inbox string `json:"inbox_url"`
+
+ // Public key of the user to register in base32
+ PublicKey string `json:"public_key"`
+
+ // Time of (re)registration. In Unix epoch microseconds)
+ RegisteredAt int64 `json:"-"`
+
+ // How long the registration lasts in microseconds
+ Duration int64 `json:"-"`
+}
+
+// A validation is created when a registration for an entry is initiated.
+// The validation stores the identity key (sha256(identity)) the secret
+// validation reference. The validation reference is sent to the identity
+// depending on the out-of-band chennel defined through the identity key type.
+type Validation struct {
+
+ // ORM
+ gorm.Model `json:"-"`
+
+ // The hash (SHA512) of the address
+ HAddress string `json:"h_address"`
+
+ // For how long should the registration last
+ Duration int64 `json:"duration"`
+
+ // (HTTPS) endpoint URL for the inbox service for this address
+ Inbox string `json:"inbox_url"`
+
+ // The activation code sent to the client
+ Code string `json:"activation_code"`
+
+ // Public key of the user to register
+ PublicKey string `json:"public_key"`
+}
+
+type ErrorDetail struct {
+
+ // Numeric error code unique to the condition.
+ // The other arguments are specific to the error value reported here.
+ Code int `json:"code"`
+
+ // Human-readable description of the error, i.e. "missing parameter",
"commitment violation", ...
+ // Should give a human-readable hint about the error's nature. Optional, may
change without notice!
+ Hint string `json:"hint,omitempty"`
+
+ // Optional detail about the specific input value that failed. May change
without notice!
+ Detail string `json:"detail,omitempty"`
+
+ // Name of the parameter that was bogus (if applicable).
+ Parameter string `json:"parameter,omitempty"`
+
+ // Path to the argument that was bogus (if applicable).
+ Path string `json:"path,omitempty"`
+
+ // Offset of the argument that was bogus (if applicable).
+ Offset string `json:"offset,omitempty"`
+
+ // Index of the argument that was bogus (if applicable).
+ Index string `json:"index,omitempty"`
+
+ // Name of the object that was bogus (if applicable).
+ Object string `json:"object,omitempty"`
+
+ // Name of the currency than was problematic (if applicable).
+ Currency string `json:"currency,omitempty"`
+
+ // Expected type (if applicable).
+ TypeExpected string `json:"type_expected,omitempty"`
+
+ // Type that was provided instead (if applicable).
+ TypeActual string `json:"type_actual,omitempty"`
+}
+
+type ValidationConfirmation struct {
+ Solution string `json:"solution"`
+}
+
+// Primary lookup function.
+// Allows the caller to query a wallet key using the hash(!) of the
+// identity, e.g. SHA512(<email address>)
+func (t *Taldir) getSingleEntry(w http.ResponseWriter, r *http.Request){
+ vars := mux.Vars(r)
+ var entry Entry
+ hs_address := saltHAddress(vars["h_address"], t.Salt)
+ var err = t.Db.First(&entry, "hs_address = ?", hs_address).Error
+ if err == nil {
+ w.Header().Set("Content-Type", "application/json")
+ resp, _ := json.Marshal(entry)
+ w.Write(resp)
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+}
+
+// Hashes an identity key (e.g. sha256(<email address>)) with a salt for
+// Lookup and storage.
+func saltHAddress(h_address string, salt string) string {
+ h := sha512.New()
+ h.Write([]byte(h_address))
+ h.Write([]byte(salt))
+ return util.EncodeBinaryToString(h.Sum(nil))
+}
+
+// Called by the registrant to validate the registration request. The
reference ID was
+// provided "out of band" using a validation method such as email or SMS
+func (t *Taldir) validationRequest(w http.ResponseWriter, r *http.Request){
+ vars := mux.Vars(r)
+ var entry Entry
+ var validation Validation
+ var confirm ValidationConfirmation
+ var errDetail ErrorDetail
+ if r.Body == nil {
+ http.Error(w, "No request body", 400)
+ return
+ }
+ err := json.NewDecoder(r.Body).Decode(&confirm)
+ if err != nil {
+ errDetail.Code = 1006 //TALER_EC_JSON_INVALID
+ errDetail.Hint = "Unable to parse JSON"
+ resp, _ := json.Marshal(errDetail)
+ w.WriteHeader(400)
+ w.Write(resp)
+ return
+ }
+ err = t.Db.First(&validation, "h_address = ?", vars["h_address"]).Error
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ expectedSolution := util.GenerateSolution(validation.PublicKey,
validation.Code)
+ if confirm.Solution != expectedSolution {
+ // FIXME how TF do we rate limit here??
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+ // FIXME: Expire validations somewhere?
+ err = t.Db.Delete(&validation).Error
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ entry.HsAddress = saltHAddress(validation.HAddress, t.Salt)
+ entry.Inbox = validation.Inbox
+ entry.Duration = validation.Duration
+ entry.RegisteredAt = time.Now().UnixMicro()
+ entry.PublicKey = validation.PublicKey
+ err = t.Db.First(&entry, "hs_address = ?", entry.HsAddress).Error
+ if err == nil {
+ t.Db.Save(&entry)
+ } else {
+ err = t.Db.Create(&entry).Error
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+
+func (t *Taldir) registerRequest(w http.ResponseWriter, r *http.Request){
+ vars := mux.Vars(r)
+ var req RegisterMessage
+ var errDetail ErrorDetail
+ var validation Validation
+ var entry Entry
+ if r.Body == nil {
+ http.Error(w, "No request body", 400)
+ return
+ }
+ err := json.NewDecoder(r.Body).Decode(&req)
+ if err != nil {
+ errDetail.Code = gana.GENERIC_JSON_INVALID
+ errDetail.Hint = "Unable to parse JSON"
+ resp, _ := json.Marshal(errDetail)
+ w.WriteHeader(400)
+ w.Write(resp)
+ return
+ }
+ if !t.Validators[vars["method"]] {
+ errDetail.Code = gana.TALDIR_METHOD_NOT_SUPPORTED
+ errDetail.Hint = "Unsupported method"
+ errDetail.Detail = "Given method: " + vars["method"]
+ resp, _ := json.Marshal(errDetail)
+ w.WriteHeader(404)
+ w.Write(resp)
+ return
+ }
+ h := sha512.New()
+ h.Write([]byte(req.Address))
+ validation.HAddress = util.EncodeBinaryToString(h.Sum(nil))
+ // We first try if there is already an entry for this address which
+ // is still valid and the duration is not extended.
+ hs_address := saltHAddress(validation.HAddress, t.Salt)
+ err = t.Db.First(&entry, "hs_address = ?", hs_address).Error
+ if err != nil {
+ lastRegValidity := entry.RegisteredAt + entry.Duration
+ requestedValidity := time.Now().UnixMicro() + req.Duration
+ earliestReRegistration := entry.RegisteredAt + t.RequestFrequency
+ // Rate limit re-registrations.
+ if time.Now().UnixMicro() < earliestReRegistration {
+ w.WriteHeader(429)
+ rlResponse := RateLimitedResponse{
+ Code: gana.TALDIR_REGISTER_RATE_LIMITED,
+ RequestFrequency: t.RequestFrequency,
+ Hint: "Registration rate limit reached",
+ }
+ jsonResp, _ := json.Marshal(rlResponse)
+ w.Write(jsonResp)
+ return
+ }
+ // Do not allow re-registrations with shorter duration.
+ if requestedValidity <= lastRegValidity {
+ w.WriteHeader(200)
+ // FIXME how to return how long it is already paid for??
+ return
+ }
+ }
+ err = t.Db.First(&validation, "h_address = ?", validation.HAddress).Error
+ validation.Code = util.GenerateCode()
+ validation.Inbox = req.Inbox
+ validation.Duration = req.Duration
+ validation.PublicKey = req.PublicKey
+ if err == nil {
+ // FIXME: Validation already pending for this address
+ // How should we proceed here? Expire old validations?
+ log.Println("Validation for this address already exists")
+ err = t.Db.Save(&validation).Error
+ } else {
+ err = t.Db.Create(&validation).Error
+ }
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ fmt.Println("Address registration request created:", validation)
+ if !t.Cfg.Section("taldir-" + vars["method"]).HasKey("command") {
+ log.Fatal(err)
+ t.Db.Delete(&validation)
+ w.WriteHeader(500)
+ return
+ }
+ command := t.Cfg.Section("taldir-" + vars["method"]).Key("command").String()
+ path, err := exec.LookPath(command)
+ if err != nil {
+ log.Println(err)
+ t.Db.Delete(&validation)
+ w.WriteHeader(500)
+ return
+ }
+ out, err := exec.Command(path, req.Address, validation.Code).Output()
+ if err != nil {
+ log.Println(err)
+ t.Db.Delete(&validation)
+ w.WriteHeader(500)
+ return
+ }
+ w.WriteHeader(202)
+ fmt.Printf("Output from method script %s is %s\n", path, out)
+}
+
+func notImplemented(w http.ResponseWriter, r *http.Request) {
+ return
+}
+
+func (t *Taldir) configResponse(w http.ResponseWriter, r *http.Request) {
+ meths := []Method{}
+ i := 0
+ for key, _ := range t.Validators {
+ var meth Method
+ meth.Name = key
+ meth.ChallengeFee = t.Cfg.Section("taldir-" +
key).Key("challenge_fee").MustString("KUDOS:1")
+ i++
+ meths = append(meths, meth)
+ }
+ cfg := VersionResponse{
+ Version: "0:0:0",
+ Name: "taler-directory",
+ MonthlyFee:
t.Cfg.Section("taldir").Key("monthly_fee").MustString("KUDOS:1"),
+ Methods: meths,
+ }
+ w.Header().Set("Content-Type", "application/json")
+ response, _ := json.Marshal(cfg)
+ w.Write(response)
+}
+
+func (t *Taldir) validationPage(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ var walletLink string
+ walletLink = "taler://taldir/" + vars["h_address"] + "/" +
vars["validation_code"] + "-wallet"
+ var png []byte
+ png, err := qrcode.Encode(walletLink, qrcode.Medium, 256)
+ if err != nil {
+ w.WriteHeader(500)
+ return
+ }
+ encodedPng := base64.StdEncoding.EncodeToString(png)
+
+ fullData := map[string]interface{}{
+ "QRCode": template.URL("data:image/png;base64," + encodedPng),
+ "WalletLink": template.URL(walletLink),
+ }
+ t.ValidationTpl.Execute(w, fullData)
+ return
+}
+
+func (t *Taldir) ClearDatabase() {
+ t.Db.Where("1 = 1").Delete(&Entry{})
+ t.Db.Where("1 = 1").Delete(&Validation{})
+}
+
+func (t *Taldir) setupHandlers() {
+ t.Router = mux.NewRouter().StrictSlash(true)
+
+ /* ToS API */
+ t.Router.HandleFunc("/terms", notImplemented).Methods("GET")
+ t.Router.HandleFunc("/privacy", notImplemented).Methods("GET")
+
+ /* Config API */
+ t.Router.HandleFunc("/config", t.configResponse).Methods("GET")
+
+
+ /* Registration API */
+ t.Router.HandleFunc("/{h_address}", t.getSingleEntry).Methods("GET")
+ t.Router.HandleFunc("/register/{method}", t.registerRequest).Methods("POST")
+ t.Router.HandleFunc("/register/{h_address}/{validation_code}",
t.validationPage).Methods("GET")
+ t.Router.HandleFunc("/{h_address}", t.validationRequest).Methods("POST")
+
+}
+
+func (t *Taldir) handleRequests() {
+
log.Fatal(http.ListenAndServe(t.Cfg.Section("taldir").Key("bind_to").MustString("localhost:11000"),
t.Router))
+}
+
+func (t *Taldir) Initialize(cfgfile string, clearDb bool) {
+ _cfg, err := ini.Load(cfgfile)
+ if err != nil {
+ fmt.Printf("Failed to read config: %v", err)
+ os.Exit(1)
+ }
+ t.Cfg = _cfg
+ if t.Cfg.Section("taldir").Key("production").MustBool(false) {
+ fmt.Println("Production mode enabled")
+ }
+
+ t.Validators = make(map[string]bool)
+ for _, a := range
strings.Split(t.Cfg.Section("taldir").Key("validators").String(), " ") {
+ t.Validators[a] = true
+ }
+ t.Validators = make(map[string]bool)
+ for _, a := range
strings.Split(t.Cfg.Section("taldir").Key("validators").String(), " ") {
+ t.Validators[a] = true
+ }
+
+ psqlconn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s
sslmode=disable",
+ t.Cfg.Section("taldir-pq").Key("host").MustString("localhost"),
+ t.Cfg.Section("taldir-pq").Key("port").MustInt64(5432),
+ t.Cfg.Section("taldir-pq").Key("user").MustString("taldir"),
+ t.Cfg.Section("taldir-pq").Key("password").MustString("secret"),
+ t.Cfg.Section("taldir-pq").Key("db_name").MustString("taldir"))
+ _db, err := gorm.Open(postgres.Open(psqlconn), &gorm.Config{})
+ if err != nil {
+ panic(err)
+ }
+ t.Db = _db
+ if err := t.Db.AutoMigrate(&Entry{}); err != nil {
+ panic(err)
+ }
+ if err := t.Db.AutoMigrate(&Validation{}); err != nil {
+ panic(err)
+ }
+ if clearDb {
+ t.ClearDatabase()
+ }
+
+ validationLandingTplFile :=
t.Cfg.Section("taldir").Key("validation_landing").MustString("templates/validation_landing.html")
+ t.ValidationTpl, err = template.ParseFiles(validationLandingTplFile)
+ if err != nil {
+ fmt.Println(err)
+ }
+ t.Salt = os.Getenv("TALDIR_SALT")
+ if "" == t.Salt {
+ t.Salt = t.Cfg.Section("taldir").Key("salt").MustString("ChangeMe")
+ }
+ t.RequestFrequency =
t.Cfg.Section("taldir").Key("request_frequency").MustInt64(1000)
+ t.setupHandlers()
+}
+
+func (t *Taldir) Run() {
+ t.handleRequests()
+}
diff --git a/cmd/taldir-server/testdata/taldir-test.conf
b/cmd/taldir-server/testdata/taldir-test.conf
new file mode 100644
index 0000000..536a741
--- /dev/null
+++ b/cmd/taldir-server/testdata/taldir-test.conf
@@ -0,0 +1,34 @@
+[taldir]
+production = false
+validators = "twitter test"
+host = "https://taldir.net"
+bind_to = "localhost:11000"
+salt = "ChangeMe"
+monthly_fee = KUDOS:1
+request_frequency = 3
+validation_landing = testdata/templates/validation_landing.html
+
+[taldir-email]
+sender = "taldir@taler.net"
+challenge_fee = KUDOS:0.5
+command = validate_email.sh
+
+[taldir-phone]
+challenge_fee = KUDOS:5
+requires_payment = true
+command = validate_phone.sh
+
+[taldir-test]
+challenge_fee = KUDOS:23
+command = taldir-validate-test
+
+[taldir-twitter]
+challenge_fee = KUDOS:2
+command = taldir-validate-twitter
+
+[taldir-pq]
+host = "localhost"
+port = 5432
+user = "taldir"
+password = "secret"
+db_name = "taldir"
diff --git a/cmd/taldir-server/testdata/templates/validation_landing.html
b/cmd/taldir-server/testdata/templates/validation_landing.html
new file mode 100644
index 0000000..ccaa770
--- /dev/null
+++ b/cmd/taldir-server/testdata/templates/validation_landing.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <!-- Required meta tags -->
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1,
shrink-to-fit=no">
+ <title>Validation Landing Page</title>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Scan this QR code with your Taler Wallet to complete your
registration.</h1>
+ <a href="{{.WalletLink}}">
+ <img src="{{.QRCode}}"/>
+ </a>
+ </div>
+ </body>
+</html>
--
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.