gnunet-svn
[Top][All Lists]
Advanced

[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.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]