/helper.go
Go | 525 lines | 421 code | 67 blank | 37 comment | 94 complexity | ea22e06aef27a359f7714490da8e968c MD5 | raw file
Possible License(s): BSD-3-Clause, MPL-2.0-no-copyleft-exception, Apache-2.0, BSD-2-Clause
- package kitty
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "html"
- "html/template"
- "math/rand"
- "net/http"
- "os"
- "regexp"
- "sort"
- "strings"
- "time"
- "github.com/gobuffalo/uuid"
- "github.com/gorilla/mux"
- "github.com/microcosm-cc/bluemonday"
- "google.golang.org/appengine"
- "google.golang.org/appengine/log"
- "google.golang.org/appengine/mail"
- "google.golang.org/appengine/urlfetch"
- )
- var (
- sanitizer = bluemonday.UGCPolicy()
- isBase64Regex = regexp.MustCompile("^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$")
- // PostcodeRegex to match 2 parts of a valid UK postcode
- PostcodeRegex = regexp.MustCompile(`^([A-Z]{1,2}\d{1,2}[A-Z]?)\s*(\d[A-Z]{2})$`)
- )
- // AppContext instantiate appengine context from request
- func AppContext(r *http.Request) context.Context {
- return appengine.NewContext(r)
- }
- // ValidateUID checks if uid is in uuid.v4 format
- func ValidateUID(uid string) error {
- uuid4, err := uuid.FromString(uid)
- if err != nil {
- return fmt.Errorf("invalid uid: %s", err.Error())
- }
- if uuid4.Version() != 4 {
- return fmt.Errorf("invalid uuid.v4 format: %s", uuid4.String())
- }
- return nil
- }
- // StructToMap converts struct to a map interface
- func StructToMap(ds interface{}) map[string]interface{} {
- var structMap map[string]interface{}
- result, err := json.Marshal(ds)
- if err != nil {
- return nil
- }
- err = json.Unmarshal(result, &structMap)
- if err != nil {
- return nil
- }
- return structMap
- }
- // MapKeys converts a map interface to slice of string keys
- func MapKeys(input map[string]interface{}) (keys []string) {
- for key := range input {
- keys = append(keys, key)
- }
- sort.Strings(keys)
- return
- }
- // HasStringInSlice checks if string element is in slice
- func HasStringInSlice(elements []string, s string) bool {
- for _, element := range elements {
- if element == s {
- return true
- }
- }
- return false
- }
- // Cleanse returns cleansed and html escapedstring
- func Cleanse(text string) string {
- return html.EscapeString(strings.TrimSpace(text))
- }
- // Sanitise returns sanitised html string allowing safe string
- func Sanitise(text string) string {
- return html.UnescapeString(sanitizer.Sanitize(strings.TrimSpace(text)))
- }
- // SanitiseInChunks returns sanitised base64 encoded string allowing safe string in 1000 length for each chunk
- func SanitiseInChunks(texts []string) []string {
- completedText := strings.TrimSpace(strings.Join(texts, ""))
- if !isBase64Regex.MatchString(completedText) {
- completedText = Sanitise(completedText)
- }
- slices := []string{}
- count := 0
- lastIndex := 0
- if len(completedText) > 1000 {
- for index := range completedText {
- count++
- if count%1001 == 0 {
- slices = append(slices, completedText[lastIndex:index])
- lastIndex = index
- }
- }
- } else {
- slices = append(slices, completedText)
- }
- return slices
- }
- // ToTitleCase parse input to title cased string separated with single space
- func ToTitleCase(input interface{}) string {
- textInput := fmt.Sprintf("%s", input)
- output := make([]string, len(textInput))
- for _, charText := range strings.Split(textInput, "") {
- if charText == strings.ToUpper(charText) {
- output = append(output, " ")
- }
- output = append(output, charText)
- }
- return strings.Join(output, "")
- }
- // MarshalStruct renders JSON string of a struct instance
- func MarshalStruct(ds interface{}) (message string) {
- response, err := json.MarshalIndent(ds, "", " ")
- if err != nil {
- return
- }
- message = string(response)
- return
- }
- type jsonResponse struct {
- Trace string `json:"trace"`
- Code int `json:"code"`
- Status string `json:"status"`
- Error string `json:"error,omitempty"`
- Data interface{} `json:"data,omitempty"`
- }
- // jsonmarshal will marshal input with indent on DEV env
- func jsonmarshal(input interface{}) (response []byte, err error) {
- if GetEnv("ENV") == "DEV" {
- response, err = json.MarshalIndent(input, "", " ")
- } else {
- response, err = json.Marshal(input)
- }
- return
- }
- // RenderJSON renders standardised json response body
- func RenderJSON(w http.ResponseWriter, r *http.Request, code int, err error, data interface{}) {
- ctx := AppContext(r)
- resp := &jsonResponse{
- Trace: appengine.RequestID(ctx),
- Code: code,
- Status: "ok",
- }
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- if err != nil {
- resp.Status = "error"
- resp.Error = fmt.Sprintf("%+v", err)
- } else {
- resp.Data = data
- }
- response, err := jsonmarshal(&resp)
- if err != nil {
- resp.Code = http.StatusUnprocessableEntity
- resp.Status = "error"
- resp.Error = fmt.Sprintf("%+v", err)
- resp.Data = nil
- // retry and fallback with simple error without body
- response, err = jsonmarshal(&resp)
- if err != nil {
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- }
- w.WriteHeader(code)
- _, err = w.Write(response)
- if err != nil {
- panic(err)
- }
- }
- // GetEnv returns requested key from environment or nil
- func GetEnv(key string) string {
- return os.Getenv(strings.ToUpper(strings.TrimSpace(key)))
- }
- // StructFromJSON parse json file to struct instance
- func StructFromJSON(i interface{}, fpath string) {
- f, err := os.Open(fpath)
- if err != nil {
- return
- }
- defer f.Close()
- err = json.NewDecoder(f).Decode(&i)
- if err != nil {
- return
- }
- }
- // Template is a struct proxy to all available templates
- type Template struct {
- // templates are pointers to Globs parsed from "public/templates/"
- templates *template.Template
- }
- // Templates store named and parsed template within map
- type Templates map[string]*template.Template
- // App proxy with mux.Router and functions
- type App struct {
- t Templates
- d interface{}
- }
- // NewApp returns new Kitty App instance
- func NewApp() *App {
- return &App{
- t: make(Templates),
- }
- }
- // IsDevelopment checks ENV environment variable
- func (app *App) IsDevelopment() bool {
- return strings.ToUpper(GetEnv("ENV")) == "DEV"
- }
- // IsProduction checks ENV environment vairable
- func (app *App) IsProduction() bool {
- return !app.IsDevelopment()
- }
- // Use middleware proxy to mux.Router
- func (app *App) Use(router *mux.Router, mw func(http.Handler) http.Handler) {
- router.Use(mw)
- }
- // SetData set data on Router instance
- func (app *App) SetData(data interface{}) {
- app.d = data
- }
- // Static serves static over prefix path
- func (app *App) Static(router *mux.Router, prefix, dir string) {
- router.PathPrefix(prefix).Handler(
- http.StripPrefix(prefix, http.FileServer(http.Dir(dir))),
- )
- }
- // Mount path to serve router
- func (app *App) Mount(router *mux.Router, path string) {
- http.Handle(path, router)
- }
- // RegisterView registers the route and handle view template rendering
- func (app *App) RegisterView(router *mux.Router, view string) {
- view = strings.TrimPrefix(view, "/")
- viewNames := strings.Split(view, ".")
- viewName := viewNames[len(viewNames)-1]
- if viewName == "" {
- viewName = "home"
- }
- router.HandleFunc(
- fmt.Sprintf("/%s", view),
- func(w http.ResponseWriter, r *http.Request) {
- app.RenderTemplate(w, viewName)
- },
- )
- }
- // Register404View renders 404 view
- func (app *App) Register404View(router *mux.Router) {
- app.RegisterView(router, "/404")
- router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- app.RenderTemplate(w, "404")
- })
- }
- // RegisterRedirect registers the route and handle redirect
- func (app *App) RegisterRedirect(router *mux.Router, view, redirectURI string, redirectCode int) {
- router.HandleFunc(
- view,
- func(w http.ResponseWriter, r *http.Request) {
- http.Redirect(w, r, redirectURI, redirectCode)
- },
- )
- }
- // RenderTemplate renders base and content template
- func (app *App) RenderTemplate(w http.ResponseWriter, name string) error {
- tplRoot := GetEnv("TEMPLATE_ROOT")
- if tplRoot == "" {
- tplRoot = "./public/templates/"
- }
- app.t[name] = template.Must(template.New("").Funcs(template.FuncMap{
- "getenv": func(key string) string {
- return GetEnv(key)
- },
- "random": func(min, max int) int {
- rand.Seed(time.Now().Unix())
- return rand.Intn(max-min) + min
- },
- "today": func() time.Time {
- return time.Now()
- },
- "dict": func(values ...interface{}) (map[string]interface{}, error) {
- if len(values)%2 != 0 {
- return nil, errors.New("invalid dict call")
- }
- dict := make(map[string]interface{}, len(values)/2)
- for i := 0; i < len(values); i += 2 {
- key, ok := values[i].(string)
- if !ok {
- return nil, errors.New("dict keys must be strings")
- }
- dict[key] = values[i+1]
- }
- return dict, nil
- },
- "js": func(s string) template.JS {
- return template.JS(s)
- },
- "safe": func(s string) template.HTML {
- return template.HTML(s)
- },
- }).ParseFiles(tplRoot+name+".html", tplRoot+"base.html"))
- return app.t[name].ExecuteTemplate(w, "base", app.d)
- }
- // Notify is a function to send datatore interface summary and a short sentence to Notification channel or Bot
- func Notify(ctx context.Context, title string, ds Datastore) {
- notification := &map[string]interface{}{
- "chat_id": GetEnv("BOT_CHAT_ID"),
- "parse_mode": "HTML",
- "text": fmt.Sprintf(`
- <b>Notification</b>: %s
- <pre>%s</pre>`, title, ds.Summary()),
- }
- payload, err := json.MarshalIndent(notification, "", " ")
- if err != nil {
- log.Errorf(ctx, "Notification cannot be marshalled: %s", err.Error())
- return
- }
- botAPI := GetEnv("BOT_API")
- if botAPI == "" {
- log.Infof(ctx, "Notification sent OK to console:\n%s", string(payload))
- return
- }
- req, err := http.NewRequest("POST", botAPI, bytes.NewBuffer(payload))
- if err != nil {
- log.Errorf(ctx, "Notification request cannot be prepared: %s", err.Error())
- return
- }
- req.Header.Set("Content-Type", "application/json")
- client := urlfetch.Client(ctx)
- resp, err := client.Do(req)
- if err != nil {
- log.Errorf(ctx, "Notification request cannot be made: %s", err.Error())
- return
- }
- defer resp.Body.Close()
- log.Infof(ctx, "Notification sent OK with response body: %s", resp.Body)
- }
- // NotifyAdmin send an email to designated admin so technical issues can be resolved
- func NotifyAdmin(ctx context.Context, topic string, entity Datastore, errSource error) {
- sender := GetEnv("EMAIL_SENDER")
- errorMessage := "No error"
- if errSource != nil {
- errorMessage = errSource.Error()
- }
- msg := &mail.Message{
- Sender: sender,
- To: []string{"anzel.lai@gmail.com", sender},
- Subject: fmt.Sprintf("%s for <%s>", topic, entity.Summary()),
- Body: fmt.Sprintf(
- "Details:\n\nTime: %s\n\nError: %s\n\nEntity: %s",
- time.Now(),
- errorMessage,
- MarshalStruct(entity),
- ),
- }
- if sender == "" {
- log.Infof(ctx, "no email sender is configured.")
- return
- }
- log.Infof(ctx, "Email to be sent: %+v", msg)
- if err := mail.Send(ctx, msg); err != nil {
- log.Errorf(ctx, "Couldn't send email: %v", err)
- }
- }
- // FormatPhoneE164 is a function to format phone number to be E164 specific format
- func FormatPhoneE164(phone string) (formatted string) {
- formatted = strings.TrimSpace(phone)
- if formatted == "" {
- return
- }
- if formatted[:3] == "+44" {
- formatted = formatted[3:]
- } else if formatted[:2] == "44" {
- formatted = formatted[2:]
- } else if formatted[0] == '0' {
- formatted = formatted[1:]
- }
- formatted = "+44" + formatted
- return
- }
- // ValidatePhone checks if mobile phone number can be used
- func ValidatePhone(
- ctx context.Context,
- phone string,
- phoneTypes []string,
- ) error {
- phone = FormatPhoneE164(phone)
- if phone == "" {
- return errors.New("phone number cannot be empty")
- }
- msgConfig := &struct {
- MsgAPILive bool
- MsgAPISender string
- MsgAPIVerifyMobileEndpoint string
- MsgAPIID string
- MsgAPIAuthToken string
- MsgWebhookSid string
- }{
- MsgAPILive: GetEnv("MSG_API_LIVE") == "true",
- MsgAPIVerifyMobileEndpoint: GetEnv("MSG_API_VERIFY_MOBILE_ENDPOINT"),
- MsgAPISender: GetEnv("MSG_API_SENDER"),
- MsgAPIID: GetEnv("MSG_API_ID"),
- MsgAPIAuthToken: GetEnv("MSG_API_AUTHTOKEN"),
- MsgWebhookSid: GetEnv("MSG_WEBHOOK_SID"),
- }
- env := GetEnv("ENV")
- if env == "PRODUCTION" {
- StructFromJSON(msgConfig, "./config.json")
- }
- if phone == msgConfig.MsgAPISender {
- return errors.New("phone number cannot be the same as the sender")
- }
- // Verify number is a mobile phone SMS capable
- phoneWithoutSign := phone[1:]
- // If SMS API is not live, just bypass and validate it
- if env != "PRODUCTION" {
- log.Infof(ctx, "ValidatePhone %s bypassed: %s", phoneWithoutSign, env)
- return nil
- }
- endpoint := fmt.Sprintf(msgConfig.MsgAPIVerifyMobileEndpoint, phoneWithoutSign)
- req, err := http.NewRequest("GET", endpoint, nil)
- if err != nil {
- log.Errorf(ctx, "ValidatePhone %s request error: %s", phoneWithoutSign, err.Error())
- return err
- }
- req.SetBasicAuth(msgConfig.MsgAPIID, msgConfig.MsgAPIAuthToken)
- client := urlfetch.Client(ctx)
- resp, err := client.Do(req)
- if err != nil {
- log.Errorf(ctx, "ValidatePhone %s error: %s", phoneWithoutSign, err.Error())
- return err
- }
- defer resp.Body.Close()
- var verifyPhoneResponse map[string]interface{}
- decoder := json.NewDecoder(resp.Body)
- err = decoder.Decode(&verifyPhoneResponse)
- if err != nil {
- log.Errorf(ctx, "ValidatePhone %s response error: %s", phoneWithoutSign, err.Error())
- return err
- }
- log.Infof(ctx, "ValidatePhone %s response body: %s", phoneWithoutSign, verifyPhoneResponse)
- carrier, ok := verifyPhoneResponse["carrier"].(map[string]interface{})
- if !ok {
- return errors.New("phone number cannot be verified")
- }
- carrierType, ok := carrier["type"].(string)
- if !ok {
- return errors.New("phone number cannot be verified")
- }
- if len(phoneTypes) > 0 && !HasStringInSlice(phoneTypes, carrierType) {
- return errors.New("phone number cannot be verified")
- }
- return nil
- }