From 2350038106e8388ede839b20331005867b092094 Mon Sep 17 00:00:00 2001 From: Mike Helmick Date: Thu, 21 May 2020 17:02:32 -0700 Subject: [PATCH] complete admin console for authorized apps --- .../authorizedapp/database/authorized_app.go | 55 +++++- .../database/authorized_app_test.go | 42 ++++- .../model/authorized_app_model.go | 38 ++++ tools/admin-console/authorizedapps.go | 176 ++++++++++++++++-- tools/admin-console/authorizedapps_test.go | 12 +- tools/admin-console/config.go | 16 +- tools/admin-console/index.go | 17 +- tools/admin-console/templates/app_view.html | 48 ++--- tools/admin-console/templates/bottom.html | 5 + tools/admin-console/templates/error.html | 16 +- tools/admin-console/templates/index.html | 31 +-- tools/admin-console/templates/top.html | 50 +++++ tools/admin-console/util.go | 40 ++++ 13 files changed, 442 insertions(+), 104 deletions(-) create mode 100644 tools/admin-console/templates/bottom.html create mode 100644 tools/admin-console/templates/top.html create mode 100644 tools/admin-console/util.go diff --git a/internal/authorizedapp/database/authorized_app.go b/internal/authorizedapp/database/authorized_app.go index 44aeb612d..df3399127 100644 --- a/internal/authorizedapp/database/authorized_app.go +++ b/internal/authorizedapp/database/authorized_app.go @@ -18,6 +18,7 @@ import ( "context" "database/sql" "fmt" + "strings" "time" "github.com/google/exposure-notifications-server/internal/authorizedapp/model" @@ -37,10 +38,62 @@ func New(db *database.DB) *AuthorizedAppDB { } } +func (aa *AuthorizedAppDB) InsertAuthorizedApp(ctx context.Context, m *model.AuthorizedApp) error { + if errors := m.Validate(); len(errors) > 0 { + return fmt.Errorf("AuthorizedApp invalid: %v", strings.Join(errors, ", ")) + } + + return aa.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error { + result, err := tx.Exec(ctx, ` + INSERT INTO + AuthorizedApp + (app_package_name, platform, allowed_regions, + safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds, + devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + `, m.AppPackageName, m.Platform, m.AllAllowedRegions(), + m.SafetyNetDisabled, m.SafetyNetApkDigestSHA256, m.SafetyNetCTSProfileMatch, m.SafetyNetBasicIntegrity, int64(m.SafetyNetPastTime.Seconds()), int64(m.SafetyNetFutureTime.Seconds()), + m.DeviceCheckDisabled, m.DeviceCheckTeamID, m.DeviceCheckKeyID, m.DeviceCheckPrivateKeySecret) + + if err != nil { + return fmt.Errorf("inserting authorizedapp: %w", err) + } + if result.RowsAffected() != 1 { + return fmt.Errorf("no rows inserted") + } + return nil + }) +} + +func (aa *AuthorizedAppDB) DeleteAuthorizedApp(ctx context.Context, appPackageName string) error { + var count int64 + err := aa.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error { + result, err := tx.Exec(ctx, ` + DELETE FROM + AuthorizedApp + WHERE + app_package_name = $1 + `, appPackageName) + if err != nil { + return fmt.Errorf("deleting authorized app: %w", err) + } + count = result.RowsAffected() + return nil + }) + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("no rows were deleted") + } + return nil +} + func (aa *AuthorizedAppDB) GetAllAuthorizedApps(ctx context.Context, sm secrets.SecretManager) ([]*model.AuthorizedApp, error) { conn, err := aa.db.Pool.Acquire(ctx) if err != nil { - return nil, fmt.Errorf("acquiring connection: %v", err) + return nil, fmt.Errorf("acquiring connection: %w", err) } defer conn.Release() diff --git a/internal/authorizedapp/database/authorized_app_test.go b/internal/authorizedapp/database/authorized_app_test.go index 1b20c9004..b248d117c 100644 --- a/internal/authorizedapp/database/authorized_app_test.go +++ b/internal/authorizedapp/database/authorized_app_test.go @@ -60,6 +60,47 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +func TestAuthorizedAppLifecycle(t *testing.T) { + if testDB == nil { + t.Skip("no test DB") + } + defer coredb.ResetTestDB(t, testDB) + ctx := context.Background() + aadb := New(testDB) + sm := &testSecretManager{ + values: map[string]string{}, + } + + source := &model.AuthorizedApp{ + AppPackageName: "myapp", + Platform: "both", + AllowedRegions: map[string]struct{}{"US": {}}, + SafetyNetDisabled: true, + DeviceCheckDisabled: true, + } + + if err := aadb.InsertAuthorizedApp(ctx, source); err != nil { + t.Fatal(err) + } + + readBack, err := aadb.GetAuthorizedApp(ctx, sm, source.AppPackageName) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(source, readBack); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } + + if err := aadb.DeleteAuthorizedApp(ctx, source.AppPackageName); err != nil { + t.Fatal(err) + } + + readBack, err = aadb.GetAuthorizedApp(ctx, sm, source.AppPackageName) + if readBack != nil { + t.Fatal("expected record to be deleted, but it wasn't") + } +} + func TestGetAuthorizedApp(t *testing.T) { if testDB == nil { t.Skip("no test DB") @@ -100,7 +141,6 @@ func TestGetAuthorizedApp(t *testing.T) { INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions) VALUES ($1, $2, $3) `, - args: []interface{}{"myapp", "android", []string{"US"}}, exp: &model.AuthorizedApp{ AppPackageName: "myapp", diff --git a/internal/authorizedapp/model/authorized_app_model.go b/internal/authorizedapp/model/authorized_app_model.go index 7e2cc4f7c..88f4edc0b 100644 --- a/internal/authorizedapp/model/authorized_app_model.go +++ b/internal/authorizedapp/model/authorized_app_model.go @@ -62,6 +62,44 @@ func NewAuthorizedApp() *AuthorizedApp { } } +func (c *AuthorizedApp) AllAllowedRegions() []string { + regions := []string{} + for k, _ := range c.AllowedRegions { + regions = append(regions, k) + } + return regions +} + +func (c *AuthorizedApp) Validate() []string { + errors := make([]string, 0) + if c.AppPackageName == "" { + errors = append(errors, "AppPackageName cannot be empty") + } + if !(c.Platform == "android" || c.Platform == "ios" || c.Platform == "both") { + errors = append(errors, "platform must be one if {'android', 'ios', or 'both'}") + } + if c.SafetyNetDisabled == false { + if c.SafetyNetPastTime < 0 { + errors = append(errors, "SafetyNetPastTime cannot be negative") + } + if c.SafetyNetFutureTime < 0 { + errors = append(errors, "SafetyNetFutureTime cannot be negative") + } + } + if c.DeviceCheckDisabled == false { + if c.DeviceCheckKeyID == "" { + errors = append(errors, "When DeviceCheck is enabled, DeviceCheckKeyID cannot be empty") + } + if c.DeviceCheckTeamID == "" { + errors = append(errors, "When DeviceCheck is enabled, DeviceCheckTeamID cannot be empty") + } + if c.DeviceCheckPrivateKeySecret == "" { + errors = append(errors, "When DeviceCheck is enabled, DeviceCheckPrivateKeySecret cannot be empty") + } + } + return errors +} + // IsIOS returns true if the platform is equal to `iosDevice` func (c *AuthorizedApp) IsIOS() bool { return c.Platform == iosDevice || c.Platform == bothPlatforms diff --git a/tools/admin-console/authorizedapps.go b/tools/admin-console/authorizedapps.go index a1c04dfb1..859a47460 100644 --- a/tools/admin-console/authorizedapps.go +++ b/tools/admin-console/authorizedapps.go @@ -18,6 +18,9 @@ import ( "encoding/base64" "fmt" "net/http" + "strconv" + "strings" + "time" authorizedappdb "github.com/google/exposure-notifications-server/internal/authorizedapp/database" "github.com/google/exposure-notifications-server/internal/authorizedapp/model" @@ -37,24 +40,171 @@ func (h *appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { h.handleGet(w, r) return + } else if r.Method == "POST" { + h.handlePost(w, r) + return } - // else if r.Method == "POST" { - // h.handlePost(w, r) - // return - //} - - m := map[string]interface{}{} - m["error"] = fmt.Errorf("Invalid request") - h.config.RenderTemplate(w, "error", &m) + + m := TemplateMap{} + m.AddErrors("Invalid request.") + h.config.RenderTemplate(w, "error", m) return } +func (h *appHandler) renderError(m TemplateMap, msg string, w http.ResponseWriter) { + m.AddErrors(msg) + h.config.RenderTemplate(w, "error", m) +} + +func populateAuthorizedApp(a *model.AuthorizedApp, r *http.Request) []string { + a.AppPackageName = r.FormValue("AppPackageName") + a.Platform = r.FormValue("Platform") + + a.AllowedRegions = make(map[string]struct{}) + regions := strings.Split(r.FormValue("Regions"), "\n") + for _, region := range regions { + a.AllowedRegions[region] = struct{}{} + } + + var err error + errors := []string{} + // SafetyNet pieces. + a.SafetyNetDisabled, err = strconv.ParseBool(r.FormValue("SafetyNetDisabled")) + if err != nil { + errors = append(errors, fmt.Sprintf("SafetyNetDisabled, invalid value: %v", err)) + } + a.SafetyNetApkDigestSHA256 = strings.Split(r.FormValue("SafetyNetApkDigestSHA256"), "\n") + a.SafetyNetBasicIntegrity, err = strconv.ParseBool(r.FormValue("SafetyNetBasicIntegrity")) + if err != nil { + errors = append(errors, fmt.Sprintf("SafetyNetBasicIntegrity, invalid value: %v", err)) + } + a.SafetyNetCTSProfileMatch, err = strconv.ParseBool(r.FormValue("SafetyNetCTSProfileMatch")) + if err != nil { + errors = append(errors, fmt.Sprintf("SafetyNetCTSProfileMatch, invalid value: %v", err)) + } + a.SafetyNetPastTime, err = time.ParseDuration(r.FormValue("SafetyNetPastTime")) + if err != nil { + errors = append(errors, fmt.Sprintf("SafetyNetPastTime, invalid value: %v", err)) + } + a.SafetyNetFutureTime, err = time.ParseDuration(r.FormValue("SafetyNetFutureTime")) + if err != nil { + errors = append(errors, fmt.Sprintf("SafetyNetFutureTime, invalid value: %v", err)) + } + + // DeviceCheck pieces + a.DeviceCheckDisabled, err = strconv.ParseBool(r.FormValue("DeviceCheckDisabled")) + if err != nil { + errors = append(errors, fmt.Sprintf("DeviceCheckDisabled, invalid value: %v", err)) + } + a.DeviceCheckKeyID = r.FormValue("DeviceCheckKeyID") + a.DeviceCheckTeamID = r.FormValue("DeviceCheckTeamID") + a.DeviceCheckPrivateKeySecret = r.FormValue("DeviceCheckPrivateKeySecret") + + return errors +} + +func decodePriorKey(priorKey string) (string, error) { + if priorKey != "" { + bytes, err := base64.StdEncoding.DecodeString(priorKey) + if err != nil { + return "", err + } + priorKey = string(bytes) + } + return priorKey, nil +} + func (h *appHandler) handlePost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + m := TemplateMap{} + err := r.ParseForm() + if err != nil { + defer h.renderError(m, "Invalid request", w) + return + } + + aadb := authorizedappdb.New(h.env.Database()) + + if action := r.FormValue("TODO"); action == "save" { + priorKey, err := decodePriorKey(r.FormValue("Key")) + if err != nil { + defer h.renderError(m, "Invalid request", w) + return + } + + // Create new, or load previous. + authApp := model.NewAuthorizedApp() + if priorKey != "" { + authApp, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), priorKey) + if err != nil { + defer h.renderError(m, "Invalid request, app to edit not found.", w) + return + } + } + errors := populateAuthorizedApp(authApp, r) + if len(errors) > 0 { + m.AddErrors(errors...) + m["app"] = authApp + h.config.RenderTemplate(w, "app_view", m) + return + } + + errors = authApp.Validate() + if len(errors) > 0 { + m.AddErrors(errors...) + m["app"] = authApp + h.config.RenderTemplate(w, "app_view", m) + return + } + + if priorKey != "" { + if err := aadb.DeleteAuthorizedApp(ctx, priorKey); err != nil { + m.AddErrors(fmt.Sprintf("Error removing old version: %v", err)) + m["app"] = authApp + h.config.RenderTemplate(w, "app_view", m) + return + } + } + + if err := aadb.InsertAuthorizedApp(ctx, authApp); err != nil { + m.AddErrors(fmt.Sprintf("Error inserting authorized app: %v", err)) + } else { + m.AddSuccess(fmt.Sprintf("Saved authorized app: %v", authApp.AppPackageName)) + } + m["app"] = authApp + h.config.RenderTemplate(w, "app_view", m) + return + + } else if action == "delete" { + priorKey, err := decodePriorKey(r.FormValue("Key")) + if err != nil { + defer h.renderError(m, "Invalid request", w) + return + } + + _, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), priorKey) + if err != nil { + defer h.renderError(m, "Couldn't find record to delete.", w) + return + } + + if err := aadb.DeleteAuthorizedApp(ctx, priorKey); err != nil { + defer h.renderError(m, fmt.Sprintf("Error deleting authorized app: %v", err), w) + return + } + + m.AddSuccess(fmt.Sprintf("Successfully deleted app `%v`", priorKey)) + m["app"] = model.NewAuthorizedApp() + h.config.RenderTemplate(w, "app_view", m) + return + } + + h.renderError(m, "Invalid action requested", w) } func (h *appHandler) handleGet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - m := map[string]interface{}{} + m := TemplateMap{} appIds := r.URL.Query()["apn"] appID := "" @@ -65,18 +215,20 @@ func (h *appHandler) handleGet(w http.ResponseWriter, r *http.Request) { authorizedApp := model.NewAuthorizedApp() if appID == "" { + m.AddJumbotron("Authorized Applications", "Create New Authorized Application") m["new"] = true } else { - aadb := authorizedappdb.NewAuthorizedAppDB(h.env.Database()) + aadb := authorizedappdb.New(h.env.Database()) var err error authorizedApp, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), appID) if err != nil { m["error"] = err - h.config.RenderTemplate(w, "error", &m) + h.config.RenderTemplate(w, "error", m) return } + m.AddJumbotron("Authorized Applications", fmt.Sprintf("Edit: `%v`", authorizedApp.AppPackageName)) } m["app"] = authorizedApp m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authorizedApp.AppPackageName)) - h.config.RenderTemplate(w, "app_view", &m) + h.config.RenderTemplate(w, "app_view", m) } diff --git a/tools/admin-console/authorizedapps_test.go b/tools/admin-console/authorizedapps_test.go index 0a2b13749..23d64c5ef 100644 --- a/tools/admin-console/authorizedapps_test.go +++ b/tools/admin-console/authorizedapps_test.go @@ -22,13 +22,19 @@ import ( ) func TestRenderAuthorizedApps(t *testing.T) { - m := map[string]interface{}{} + // Hello developer! + // If this test fails, it's likely that you changed something in + // internal/authorizedapp/model/ + // And whatever you changed is used in the + // tools/admin-console/templates/app_view.html + // That is what caused the test failure. + m := TemplateMap{} authorizedApp := model.NewAuthorizedApp() m["app"] = authorizedApp recorder := httptest.NewRecorder() - config := Config{TemplatePath: "templates"} - err := config.RenderTemplate(recorder, "app_view", &m) + config := Config{TemplatePath: "templates", TopFile: "top", BotFile: "bottom"} + err := config.RenderTemplate(recorder, "app_view", m) if err != nil { t.Fatalf("error rendoring template: %v", err) } diff --git a/tools/admin-console/config.go b/tools/admin-console/config.go index b9599ebf9..3dc778c75 100644 --- a/tools/admin-console/config.go +++ b/tools/admin-console/config.go @@ -32,6 +32,8 @@ type Config struct { Port string `envconfig:"PORT" default:"8080"` TemplatePath string `envconfig:"TEMPLATE_DIR" default:"tools/admin-console/templates"` Database *database.Config + TopFile string `envconfig:"TOP_FILE" default:"top"` + BotFile string `envconfig:"BOTTOM_FILE" default:"bottom"` } // DB returns the configuration for the databse. @@ -39,21 +41,27 @@ func (c *Config) DB() *database.Config { return c.Database } -func (c *Config) RenderTemplate(w http.ResponseWriter, tmpl string, p *map[string]interface{}) error { - file := fmt.Sprintf("%s/%s.html", c.TemplatePath, tmpl) - t, err := template.ParseFiles(file) +func (c *Config) RenderTemplate(w http.ResponseWriter, tmpl string, p TemplateMap) error { + files := []string{ + fmt.Sprintf("%s/%s.html", c.TemplatePath, c.TopFile), + fmt.Sprintf("%s/%s.html", c.TemplatePath, tmpl), + fmt.Sprintf("%s/%s.html", c.TemplatePath, c.BotFile), + } + + t, err := template.ParseFiles(files...) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) log.Printf("ERROR: %v", err) return err } - if err := t.Execute(w, p); err != nil { + if err := t.ExecuteTemplate(w, "view", p); err != nil { message := fmt.Sprintf("error rendering template: %v", err) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(message)) log.Printf("ERROR: %v", err) return fmt.Errorf("error rendering template: %w", err) } + return nil } diff --git a/tools/admin-console/index.go b/tools/admin-console/index.go index 6e2796dfd..5ec4c2ba2 100644 --- a/tools/admin-console/index.go +++ b/tools/admin-console/index.go @@ -18,6 +18,7 @@ import ( "net/http" aadb "github.com/google/exposure-notifications-server/internal/authorizedapp/database" + exdb "github.com/google/exposure-notifications-server/internal/export/database" "github.com/google/exposure-notifications-server/internal/serverenv" ) @@ -32,26 +33,30 @@ func NewIndexHandler(c *Config, env *serverenv.ServerEnv) *indexHandler { func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - m := map[string]interface{}{} + m := TemplateMap{} // Load authorized apps for index. - db := aadb.NewAuthorizedAppDB(h.env.Database()) + db := aadb.New(h.env.Database()) apps, err := db.GetAllAuthorizedApps(ctx, h.env.SecretManager()) if err != nil { m["error"] = err - h.config.RenderTemplate(w, "error", &m) + h.config.RenderTemplate(w, "error", m) return } m["apps"] = apps // Load export configurations. - exports, err := h.env.Database().GetAllExportConfigs(ctx) + + exportDB := exdb.New(h.env.Database()) + exports, err := exportDB.GetAllExportConfigs(ctx) if err != nil { m["error"] = err - h.config.RenderTemplate(w, "error", &m) + h.config.RenderTemplate(w, "error", m) return } m["exports"] = exports - h.config.RenderTemplate(w, "index", &m) + m.AddTitle("Exposure Notifications Server - Admin Console") + m.AddJumbotron("Exposure Notification Server", "Admin Console") + h.config.RenderTemplate(w, "index", m) } diff --git a/tools/admin-console/templates/app_view.html b/tools/admin-console/templates/app_view.html index e8dc1d3e5..b34ddb781 100644 --- a/tools/admin-console/templates/app_view.html +++ b/tools/admin-console/templates/app_view.html @@ -1,26 +1,5 @@ - - - Edit Authorized App - - - - - - - -
-
-

Authorized App Details

-
+{{define "view"}} +{{template "top" .}} {{if .new}}

Create New Authorized Application

@@ -29,7 +8,7 @@

Edit Application {{.app.AppPackageName}}

{{end}}
- +
@@ -40,17 +19,17 @@

Edit Application {{.app.AppPackageName}}

- - +
- + One per line, leave blank for all
@@ -69,14 +48,14 @@

Edit Application {{.app.AppPackageName}}

- + One per line. Normally there is one, but could be more than one if there are test/release builds.
- @@ -85,7 +64,7 @@

Edit Application {{.app.AppPackageName}}

- @@ -139,12 +118,11 @@

Edit Application {{.app.AppPackageName}}


- + Cancel - + -
- - +{{template "bottom" .}} +{{end}} diff --git a/tools/admin-console/templates/bottom.html b/tools/admin-console/templates/bottom.html new file mode 100644 index 000000000..56cad6b38 --- /dev/null +++ b/tools/admin-console/templates/bottom.html @@ -0,0 +1,5 @@ +{{define "bottom"}} +
+ + +{{end}} diff --git a/tools/admin-console/templates/error.html b/tools/admin-console/templates/error.html index ca3b5bfac..9a8e8a922 100644 --- a/tools/admin-console/templates/error.html +++ b/tools/admin-console/templates/error.html @@ -1,15 +1 @@ - - - Exposure Notifications Server - Error - - - - -

Aw Snap, you got an error.

- -
-{{.error}}
-
- - - + diff --git a/tools/admin-console/templates/index.html b/tools/admin-console/templates/index.html index cea52720e..bc5eaa0e9 100644 --- a/tools/admin-console/templates/index.html +++ b/tools/admin-console/templates/index.html @@ -1,27 +1,5 @@ - - - Exposure Notifications Server - Console - - - - - -
- -
-

Exposure Notifications Server

-

Admin Console

-
-
+{{define "view"}} +{{template "top" .}}

Authorized Applications

    @@ -48,6 +26,5 @@

    Export Configurations

    {{end}}
-
- - +{{template "bottom" .}} +{{end}} diff --git a/tools/admin-console/templates/top.html b/tools/admin-console/templates/top.html new file mode 100644 index 000000000..03f031a49 --- /dev/null +++ b/tools/admin-console/templates/top.html @@ -0,0 +1,50 @@ +{{define "top"}} + + + {{if .title}} {{.title}} {{else}} Exposure Notifications {{end}} + + + + + +
+ +{{if .jumbotron}} +
+

{{.jumbotron}}

+ {{if .jumbotronsub}} +

{{.jumbotronsub}}

+ {{end}} +
+
+{{end}} + +{{if .error}} + +{{end}} +{{if .success}} + +{{end}} + +{{end}} diff --git a/tools/admin-console/util.go b/tools/admin-console/util.go new file mode 100644 index 000000000..98ec6e016 --- /dev/null +++ b/tools/admin-console/util.go @@ -0,0 +1,40 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, softwar +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +type TemplateMap map[string]interface{} + +func (t TemplateMap) AddTitle(title string) { + t["title"] = title +} + +func (t TemplateMap) AddJumbotron(headline, subheader string) { + t["jumbotron"] = headline + if subheader != "" { + t["jumbotronsub"] = subheader + } +} + +func (t TemplateMap) AddSubNav(name string) { + t["subnav"] = name +} + +func (t TemplateMap) AddErrors(errors ...string) { + t["error"] = errors +} + +func (t TemplateMap) AddSuccess(success ...string) { + t["success"] = success +}