From 9738f6027d3adbaea62879a3b8dc6fe30d1257dc Mon Sep 17 00:00:00 2001 From: Winni Neessen Date: Sat, 4 Sep 2021 16:19:46 +0200 Subject: [PATCH 1/2] #8: Complete refactor to use echo as underlying framework - The whole code base has been refactored to make use of the echo framework - Validation functions have been implemented as middleware - Code cleanup to reduce redundant code - Files have been consolidated to reduce sub packages --- .idea/.gitignore | 8 + .idea/inspectionProfiles/Project_Default.xml | 12 + .idea/js-mailer.iml | 9 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + api/api.go | 12 + {apirequest => api}/form.go | 10 +- api/ping.go | 24 ++ api/sendform.go | 84 +++++ api/sendform_mw.go | 321 +++++++++++++++++++ api/token.go | 99 ++++++ apirequest/apirequest.go | 70 ---- apirequest/gettoken.go | 91 ------ apirequest/sendform.go | 197 ------------ config/config.go | 18 +- form/form.go | 14 +- go.mod | 2 + go.sum | 37 ++- logging/logging.go | 36 --- main.go | 54 +--- response/error.go | 67 ++-- response/success.go | 30 +- server/router_api.go | 22 ++ server/server.go | 90 ++++++ validation/field.go | 61 ---- validation/hcaptcha.go | 51 --- validation/recaptcha.go | 51 --- 27 files changed, 793 insertions(+), 691 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/js-mailer.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 api/api.go rename {apirequest => api}/form.go (66%) create mode 100644 api/ping.go create mode 100644 api/sendform.go create mode 100644 api/sendform_mw.go create mode 100644 api/token.go delete mode 100644 apirequest/apirequest.go delete mode 100644 apirequest/gettoken.go delete mode 100644 apirequest/sendform.go delete mode 100644 logging/logging.go create mode 100644 server/router_api.go create mode 100644 server/server.go delete mode 100644 validation/field.go delete mode 100644 validation/hcaptcha.go delete mode 100644 validation/recaptcha.go diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..09dbac8 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/.idea/js-mailer.iml b/.idea/js-mailer.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/js-mailer.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..fd6c8d0 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..ba7b38e --- /dev/null +++ b/api/api.go @@ -0,0 +1,12 @@ +package api + +import ( + "github.com/ReneKroon/ttlcache/v2" + "github.com/wneessen/js-mailer/config" +) + +// Route represents an API route object +type Route struct { + Cache *ttlcache.Cache + Config *config.Config +} diff --git a/apirequest/form.go b/api/form.go similarity index 66% rename from apirequest/form.go rename to api/form.go index 070ba73..3740753 100644 --- a/apirequest/form.go +++ b/api/form.go @@ -1,4 +1,4 @@ -package apirequest +package api import ( "fmt" @@ -8,20 +8,20 @@ import ( // GetForm gets a form.Form object either from the in-memory cache or if not cached // yet, from the file system -func (a *ApiRequest) GetForm(i string) (form.Form, error) { +func (r *Route) GetForm(i string) (form.Form, error) { var formObj form.Form - cacheForm, err := a.Cache.Get(fmt.Sprintf("formObj_%s", i)) + cacheForm, err := r.Cache.Get(fmt.Sprintf("formObj_%s", i)) if err != nil && err != ttlcache.ErrNotFound { return formObj, err } if cacheForm != nil { formObj = cacheForm.(form.Form) } else { - formObj, err = form.NewForm(a.Config, i) + formObj, err = form.NewForm(r.Config, i) if err != nil { return formObj, err } - if err := a.Cache.Set(fmt.Sprintf("formObj_%s", formObj.Id), formObj); err != nil { + if err := r.Cache.Set(fmt.Sprintf("formObj_%s", formObj.Id), formObj); err != nil { return formObj, err } } diff --git a/api/ping.go b/api/ping.go new file mode 100644 index 0000000..a957603 --- /dev/null +++ b/api/ping.go @@ -0,0 +1,24 @@ +package api + +import ( + "github.com/labstack/echo/v4" + "net/http" +) + +// PingResponse reflects the JSON structure for a ping response +type PingResponse struct { + StatusCode int + Status string + Data interface{} +} + +// Ping is a test route for the API +func (r *Route) Ping(c echo.Context) error { + return c.JSON(http.StatusOK, &PingResponse{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Data: map[string]string{ + "Ping": "Pong", + }, + }) +} diff --git a/api/sendform.go b/api/sendform.go new file mode 100644 index 0000000..06e83fd --- /dev/null +++ b/api/sendform.go @@ -0,0 +1,84 @@ +package api + +import ( + "fmt" + "github.com/wneessen/js-mailer/response" + "net/http" + "time" + + "github.com/go-mail/mail" + "github.com/labstack/echo/v4" +) + +// SentSuccessfull represents confirmation JSON structure for a successfully sent message +type SentSuccessfull struct { + FormId string `json:"form_id"` + SendTime int64 `json:"send_time"` +} + +// SendForm handles the HTTP form sending API request +func (r *Route) SendForm(c echo.Context) error { + sr := c.Get("formobj").(*SendFormRequest) + if sr == nil { + c.Logger().Errorf("no form object found in context") + return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error") + } + + // Compose the mail message + mailMsg := mail.NewMessage() + mailMsg.SetHeader("From", sr.FormObj.Sender) + mailMsg.SetHeader("To", sr.FormObj.Recipients...) + mailMsg.SetHeader("Subject", sr.FormObj.Content.Subject) + + mailBody := "The following form fields have been transmitted:\n" + for _, k := range sr.FormObj.Content.Fields { + if v := c.FormValue(k); v != "" { + mailBody = fmt.Sprintf("%s\n* %s => %s", mailBody, k, v) + } + } + mailMsg.SetBody("text/plain", mailBody) + + // Send the mail message + var serverTimeout time.Duration + var err error + serverTimeout, err = time.ParseDuration(sr.FormObj.Server.Timeout) + if err != nil { + c.Logger().Warnf("Could not parse configured server timeout: %s", err) + serverTimeout = time.Second * 5 + } + mailDailer := mail.NewDialer(sr.FormObj.Server.Host, sr.FormObj.Server.Port, sr.FormObj.Server.Username, + sr.FormObj.Server.Password) + mailDailer.Timeout = serverTimeout + if sr.FormObj.Server.ForceTLS { + mailDailer.StartTLSPolicy = mail.MandatoryStartTLS + } + mailSender, err := mailDailer.Dial() + if err != nil { + c.Logger().Errorf("Could not connect to configured mail server: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, &response.ErrorObj{ + Message: "could not connect to configured mail server", + Data: err.Error(), + }) + } + defer func() { + if err := mailSender.Close(); err != nil { + c.Logger().Errorf("Failed to close mail server connection: %s", err) + } + }() + if err := mail.Send(mailSender, mailMsg); err != nil { + c.Logger().Errorf("Could not send mail message: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, &response.ErrorObj{ + Message: "could not send mail message", + Data: err.Error(), + }) + } + + return c.JSON(http.StatusOK, response.SuccessResponse{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Data: &SentSuccessfull{ + FormId: sr.FormObj.Id, + SendTime: time.Now().Unix(), + }, + }) +} diff --git a/api/sendform_mw.go b/api/sendform_mw.go new file mode 100644 index 0000000..4d4e0f1 --- /dev/null +++ b/api/sendform_mw.go @@ -0,0 +1,321 @@ +package api + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/ReneKroon/ttlcache/v2" + "github.com/labstack/echo/v4" + "github.com/wneessen/js-mailer/form" + "github.com/wneessen/js-mailer/response" +) + +// SendFormRequest reflects the structure of the send form request data +type SendFormRequest struct { + FormId string `param:"fid"` + FormObj *form.Form + Token string `param:"token"` + TokenResp *TokenResponse +} + +// CaptchaResponse reflect the API response from various 3rd party captcha services +type CaptchaResponse struct { + Success bool `json:"success"` + ChallengeTimestamp string `json:"challenge_ts"` + Hostname string `json:"hostname"` +} + +// HcaptchaResponse is the CaptchaResponse for hCaptcha +type HcaptchaResponse CaptchaResponse + +// ReCaptchaResponse is the CaptchaResponse for Google ReCaptcha +type ReCaptchaResponse CaptchaResponse + +// SendFormBindForm is a middleware that validates the provided form data and binds +// it to a SendFormRequest object +func (r *Route) SendFormBindForm(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + sr := &SendFormRequest{} + if err := c.Bind(sr); err != nil { + c.Logger().Errorf("failed to bind request to SendFormRequest object: %s", err) + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + // Let's retrieve the formObj from cache + cacheObj, err := r.Cache.Get(sr.Token) + if err == ttlcache.ErrNotFound { + return echo.NewHTTPError(http.StatusNotFound, "not a valid send URL") + } + if err != nil && err != ttlcache.ErrNotFound { + c.Logger().Errorf("failed to look up token in cache: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, &response.ErrorObj{ + Message: "failed to look up token in cache", + Data: err.Error(), + }) + } + if cacheObj != nil { + TokenRespObj := cacheObj.(TokenResponse) + sr.TokenResp = &TokenRespObj + } + if sr.TokenResp != nil && sr.TokenResp.FormId != sr.FormId { + c.Logger().Warn("URL form id does not match the cached form object id") + return echo.NewHTTPError(http.StatusBadRequest, "invalid form id") + } + defer func() { + if err := r.Cache.Remove(sr.Token); err != nil { + c.Logger().Errorf("failed to delete used token from cache: %s", err) + } + }() + + // Let's try to read formobj from cache + formObj, err := r.GetForm(sr.FormId) + if err != nil { + c.Logger().Errorf("failed get form object: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, "form lookup failed") + } + + sr.FormObj = &formObj + c.Set("formobj", sr) + + return next(c) + } +} + +// SendFormReqFields is a middleware that validates that all required fields are set in +// the SendFormRequest object +func (r *Route) SendFormReqFields(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + sr := c.Get("formobj").(*SendFormRequest) + if sr == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "no valid form object found") + } + + var invalidFields []string + fieldError := make(map[string]string) + for _, f := range sr.FormObj.Validation.Fields { + v := c.FormValue(f.Name) + if f.Required && v == "" { + invalidFields = append(invalidFields, f.Name) + fieldError[f.Name] = "field is required, but missing" + continue + } + + switch f.Type { + case "text": + continue + case "email": + mailRegExp, err := regexp.Compile("^[a-zA-Z0-9.!#$%&'*+/\\=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + if err != nil { + c.Logger().Errorf("Failed to compile email comparison regexp: %s", err) + continue + } + if !mailRegExp.Match([]byte(v)) { + c.Logger().Debugf("Form field is expected to be of type email but does not match this requirementd: %s", f.Name) + invalidFields = append(invalidFields, f.Name) + fieldError[f.Name] = "field is expected to be of type email, but does not match" + continue + } + case "number": + numRegExp, err := regexp.Compile("^[0-9]+$") + if err != nil { + c.Logger().Errorf("Failed to compile email comparison regexp: %s", err) + continue + } + if !numRegExp.Match([]byte(v)) { + c.Logger().Debugf("Form field is expected to be of type number but does not match this requirementd: %s", f.Name) + invalidFields = append(invalidFields, f.Name) + fieldError[f.Name] = "field is expected to be of type number, but does not match" + continue + } + case "bool": + boolRegExp, err := regexp.Compile("^(?i)(true|false|0|1|on|off)$") + if err != nil { + c.Logger().Errorf("Failed to compile boolean comparison regexp: %s", err) + continue + } + if !boolRegExp.Match([]byte(v)) { + c.Logger().Debugf("Form field is expected to be of type boolean but does not match this requirementd: %s", f.Name) + invalidFields = append(invalidFields, f.Name) + fieldError[f.Name] = "field is expected to be of type bool, but does not match" + continue + } + case "matchval": + if v != f.Value { + invalidFields = append(invalidFields, f.Name) + fieldError[f.Name] = "field is expected match the configured match value, but isn't" + } + continue + default: + continue + } + } + if len(invalidFields) > 0 { + c.Logger().Errorf("Form field validation failed: %s", strings.Join(invalidFields, ", ")) + var errorMsg []string + for _, f := range invalidFields { + errorMsg = append(errorMsg, fmt.Sprintf("%s: %s", f, fieldError[f])) + } + return echo.NewHTTPError(http.StatusBadRequest, &response.ErrorObj{ + Message: "fields(s) validation failed", + Data: fieldError, + }) + } + + return next(c) + } +} + +// SendFormHoneypot is a middleware that checks that a configured honeypot field is not +// filled with any data +func (r *Route) SendFormHoneypot(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + sr := c.Get("formobj").(*SendFormRequest) + if sr == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "no valid form object found") + } + + if sr.FormObj.Validation.Honeypot != nil { + if c.FormValue(*sr.FormObj.Validation.Honeypot) != "" { + c.Logger().Warnf("form includes a honeypot field which is not empty. Denying request") + return echo.NewHTTPError(http.StatusBadRequest, "invalid form request data") + } + } + + return next(c) + } +} + +// SendFormHcaptcha is a middleware that checks the form data against hCaptcha +func (r *Route) SendFormHcaptcha(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + sr := c.Get("formobj").(*SendFormRequest) + if sr == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "no valid form object found") + } + + if sr.FormObj.Validation.Hcaptcha.Enabled { + hcapResponse := c.FormValue("h-captcha-response") + if hcapResponse == "" { + return echo.NewHTTPError(http.StatusBadRequest, "missing hCaptcha response") + } + + // Create a HTTP request + postData := url.Values{ + "response": {hcapResponse}, + "secret": {sr.FormObj.Validation.Hcaptcha.SecretKey}, + } + httpResp, err := http.PostForm("https://hcaptcha.com/siteverify", postData) + if err != nil { + c.Logger().Errorf("failed to post HTTP request to hCaptcha: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, "hCaptcha validation failed") + } + + var respBody bytes.Buffer + _, err = respBody.ReadFrom(httpResp.Body) + if err != nil { + c.Logger().Errorf("reading HTTP response body failed: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, "hCaptcha validation failed") + } + if httpResp.StatusCode == http.StatusOK { + var hcapResp HcaptchaResponse + if err := json.Unmarshal(respBody.Bytes(), &hcapResp); err != nil { + c.Logger().Errorf("HTTP repsonse JSON unmarshalling failed: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, "hCaptcha validation failed") + } + if !hcapResp.Success { + return echo.NewHTTPError(http.StatusBadRequest, + "hCaptcha challenge-response validation failed") + } + return next(c) + } + + return echo.NewHTTPError(http.StatusBadRequest, + "hCaptcha challenge-response validation failed") + } + + return next(c) + } +} + +// SendFormRecaptcha is a middleware that checks the form data against Google ReCaptcha +func (r *Route) SendFormRecaptcha(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + sr := c.Get("formobj").(*SendFormRequest) + if sr == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "no valid form object found") + } + + if sr.FormObj.Validation.Recaptcha.Enabled { + recapResponse := c.FormValue("g-recaptcha-response") + if recapResponse == "" { + return echo.NewHTTPError(http.StatusBadRequest, "missing reCaptcha response") + } + + // Create a HTTP request + postData := url.Values{ + "response": {recapResponse}, + "secret": {sr.FormObj.Validation.Recaptcha.SecretKey}, + } + httpResp, err := http.PostForm("https://www.google.com/recaptcha/api/siteverify", postData) + if err != nil { + c.Logger().Errorf("failed to post HTTP request to reCaptcha: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, "reCaptcha validation failed") + } + + var respBody bytes.Buffer + _, err = respBody.ReadFrom(httpResp.Body) + if err != nil { + c.Logger().Errorf("reading HTTP response body failed: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, "reCaptcha validation failed") + } + if httpResp.StatusCode == http.StatusOK { + var recapResp ReCaptchaResponse + if err := json.Unmarshal(respBody.Bytes(), &recapResp); err != nil { + c.Logger().Errorf("HTTP repsonse JSON unmarshalling failed: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, "reCaptcha validation failed") + } + if !recapResp.Success { + return echo.NewHTTPError(http.StatusBadRequest, + "reCaptcha challenge-response validation failed") + } + return next(c) + } + + return echo.NewHTTPError(http.StatusBadRequest, + "reCaptcha challenge-response validation failed") + } + + return next(c) + } +} + +// SendFormCheckToken is a middleware that checks the form security token +func (r *Route) SendFormCheckToken(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + sr := c.Get("formobj").(*SendFormRequest) + if sr == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "no valid form object found") + } + + reqOrigin := c.Request().Header.Get("origin") + if reqOrigin == "" { + c.Logger().Errorf("no origin domain set in HTTP request") + return echo.NewHTTPError(http.StatusUnauthorized, "domain not allowed to access form") + } + tokenText := fmt.Sprintf("%s_%d_%d_%s_%s", reqOrigin, sr.TokenResp.CreateTime, + sr.TokenResp.ExpireTime, sr.FormObj.Id, sr.FormObj.Secret) + tokenSha := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenText))) + if tokenSha != sr.Token { + c.Logger().Errorf("security token does not match") + return echo.NewHTTPError(http.StatusUnauthorized, "domain not allowed to access form") + } + + return next(c) + } +} diff --git a/api/token.go b/api/token.go new file mode 100644 index 0000000..ca199a4 --- /dev/null +++ b/api/token.go @@ -0,0 +1,99 @@ +package api + +import ( + "crypto/sha256" + "fmt" + "github.com/labstack/echo/v4" + "github.com/wneessen/js-mailer/response" + "net/http" + "net/url" + "time" +) + +// TokenRequest reflects the incoming gettoken request data that for the parameter binding +type TokenRequest struct { + FormId string `query:"formid" form:"formid"` +} + +// TokenResponse reflects the JSON response struct for token request +type TokenResponse struct { + Token string `json:"token"` + FormId string `json:"form_id"` + CreateTime int64 `json:"create_time,omitempty"` + ExpireTime int64 `json:"expire_time,omitempty"` + Url string `json:"url"` + EncType string `json:"enc_type"` + Method string `json:"method"` +} + +// GetToken handles the HTTP token requests and return a TokenResponse on success or +// an error on failure +func (r *Route) GetToken(c echo.Context) error { + fr := &TokenRequest{} + if err := c.Bind(fr); err != nil { + c.Logger().Errorf("failed to bind request to TokenRequest object: %s", err) + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + // Let's try to read formobj from cache + formObj, err := r.GetForm(fr.FormId) + if err != nil { + c.Logger().Errorf("failed to get form object: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, &response.ErrorObj{ + Message: "failed to get form object", + Data: "invalid form id or form configuration broken", + }) + } + + // Let's validate the Origin header + isValid := false + reqOrigin := c.Request().Header.Get("origin") + if reqOrigin == "" { + c.Logger().Errorf("no origin domain set in HTTP request header") + return echo.NewHTTPError(http.StatusUnauthorized, + "domain is not authorized to access the requested form") + } + for _, d := range formObj.Domains { + if reqOrigin == d || reqOrigin == fmt.Sprintf("http://%s", d) || + reqOrigin == fmt.Sprintf("https://%s", d) { + isValid = true + } + } + if !isValid { + c.Logger().Errorf("domain %q not in allowed domains list for form %s", reqOrigin, formObj.Id) + return echo.NewHTTPError(http.StatusUnauthorized, + "domain is not authorized to access the requested form") + } + c.Response().Header().Set("Access-Control-Allow-Origin", reqOrigin) + + // Generate the token + reqScheme := "http" + if c.Request().Header.Get("X-Forwarded-Proto") == "https" || c.Request().TLS != nil { + reqScheme = "https" + } + nowTime := time.Now() + expTime := time.Now().Add(time.Minute * 10) + tokenText := fmt.Sprintf("%s_%d_%d_%s_%s", reqOrigin, nowTime.Unix(), expTime.Unix(), + formObj.Id, formObj.Secret) + tokenSha := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenText))) + respToken := TokenResponse{ + Token: tokenSha, + FormId: formObj.Id, + CreateTime: nowTime.Unix(), + ExpireTime: expTime.Unix(), + Url: fmt.Sprintf("%s://%s/api/v1/send/%s/%s", reqScheme, + c.Request().Host, url.QueryEscape(formObj.Id), url.QueryEscape(tokenSha)), + EncType: "multipart/form-data", + Method: "post", + } + if err := r.Cache.Set(tokenSha, respToken); err != nil { + c.Logger().Errorf("Failed to store response token in cache: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error") + } + + return c.JSON(http.StatusOK, &response.SuccessResponse{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Data: respToken, + }) +} diff --git a/apirequest/apirequest.go b/apirequest/apirequest.go deleted file mode 100644 index a534c86..0000000 --- a/apirequest/apirequest.go +++ /dev/null @@ -1,70 +0,0 @@ -package apirequest - -import ( - "github.com/ReneKroon/ttlcache/v2" - log "github.com/sirupsen/logrus" - "github.com/wneessen/js-mailer/config" - "github.com/wneessen/js-mailer/form" - "github.com/wneessen/js-mailer/response" - "net/http" - "strings" -) - -// ApiRequest reflects a new Api request object -type ApiRequest struct { - Cache *ttlcache.Cache - Config *config.Config - IsHttps bool - Scheme string - FormId string - Token string - FormObj *form.Form -} - -// RequestHandler handles an incoming HTTP request on the API routes and -// routes them accordingly to its request type -func (a *ApiRequest) RequestHandler(w http.ResponseWriter, r *http.Request) { - l := log.WithFields(log.Fields{ - "action": "apiRequest.RequestHandler", - }) - - remoteAddr := r.RemoteAddr - if r.Header.Get("X-Forwarded-For") != "" { - remoteAddr = r.Header.Get("X-Forwarded-For") - } - if r.Header.Get("X-Real-Ip") != "" { - remoteAddr = r.Header.Get("X-Real-Ip") - } - l.Infof("New request to %s from %s", r.URL.String(), remoteAddr) - - a.Scheme = "http" - if r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil { - a.IsHttps = true - a.Scheme = "https" - } - - // Set general response header - w.Header().Set("Access-Control-Allow-Origin", "*") - - switch { - case r.URL.String() == "/api/v1/token": - a.GetToken(w, r) - return - case strings.HasPrefix(r.URL.String(), "/api/v1/send/"): - code, err := a.SendFormParse(r) - if err != nil { - l.Errorf("Failed to parse send request: %s", err) - response.ErrorJsonData(w, code, "Failed parsing send request", err.Error()) - return - } - code, err = a.SendFormValidate(r) - if err != nil { - response.ErrorJsonData(w, code, "Validation failed", err.Error()) - return - } - a.SendForm(w, r) - return - default: - response.ErrorJson(w, 404, "Unknown API route") - } -} diff --git a/apirequest/gettoken.go b/apirequest/gettoken.go deleted file mode 100644 index d36aa27..0000000 --- a/apirequest/gettoken.go +++ /dev/null @@ -1,91 +0,0 @@ -package apirequest - -import ( - "crypto/sha256" - "fmt" - log "github.com/sirupsen/logrus" - "github.com/wneessen/js-mailer/response" - "net/http" - "time" -) - -// TokenResponseJson reflects the JSON response struct for token request -type TokenResponseJson struct { - Token string `json:"token"` - FormId string `json:"form_id"` - CreateTime int64 `json:"create_time,omitempty"` - ExpireTime int64 `json:"expire_time,omitempty"` - Url string `json:"url"` - EncType string `json:"enc_type"` - Method string `json:"method"` -} - -// GetToken handles the HTTP token requests and return a TokenResponseJson on success or -// an response.ErrorResponseJson on failure -func (a *ApiRequest) GetToken(w http.ResponseWriter, r *http.Request) { - l := log.WithFields(log.Fields{ - "action": "apiRequest.GetToken", - }) - - var formId string - if err := r.ParseMultipartForm(a.Config.Forms.MaxLength); err != nil { - l.Errorf("Failed to parse form parameters: %s", err) - response.ErrorJson(w, 500, err.Error()) - return - } - formId = r.Form.Get("formid") - if formId == "" { - response.ErrorJson(w, 400, "Missing formid") - return - } - - // Let's try to read formobj from cache - formObj, err := a.GetForm(formId) - if err != nil { - l.Errorf("Failed get formObj: %s", err) - response.ErrorJson(w, 500, err.Error()) - return - } - - // Let's validate the Origin header - isValid := false - reqOrigin := r.Header.Get("origin") - if reqOrigin == "" { - l.Errorf("No origin domain set in HTTP request") - response.ErrorJson(w, 401, "Domain is not allowed to access the requested form") - return - } - for _, d := range formObj.Domains { - if reqOrigin == d || reqOrigin == fmt.Sprintf("http://%s", d) || - reqOrigin == fmt.Sprintf("https://%s", d) { - isValid = true - } - } - if !isValid { - l.Errorf("Domain %q not in allowed domains list for form %s", reqOrigin, formObj.Id) - response.ErrorJson(w, 401, "Domain is not allowed to access the requested form") - return - } - w.Header().Set("Access-Control-Allow-Origin", reqOrigin) - - // Generate the token - nowTime := time.Now() - expTime := time.Now().Add(time.Minute * 10) - tokenText := fmt.Sprintf("%s_%d_%d_%s_%s", reqOrigin, nowTime.Unix(), expTime.Unix(), formObj.Id, formObj.Secret) - tokenSha := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenText))) - respToken := TokenResponseJson{ - Token: tokenSha, - FormId: formObj.Id, - CreateTime: nowTime.Unix(), - ExpireTime: expTime.Unix(), - Url: fmt.Sprintf("%s://%s/api/v1/send/%s/%s", a.Scheme, r.Host, formObj.Id, tokenSha), - EncType: "multipart/form-data", - Method: "post", - } - if err := a.Cache.Set(tokenSha, respToken); err != nil { - l.Errorf("Failed to store response token in cache: %s", err) - response.ErrorJson(w, 500, "Internal Server Error") - return - } - response.SuccessJson(w, 200, respToken) -} diff --git a/apirequest/sendform.go b/apirequest/sendform.go deleted file mode 100644 index 090ea48..0000000 --- a/apirequest/sendform.go +++ /dev/null @@ -1,197 +0,0 @@ -package apirequest - -import ( - "crypto/sha256" - "fmt" - "github.com/ReneKroon/ttlcache/v2" - "github.com/go-mail/mail" - log "github.com/sirupsen/logrus" - "github.com/wneessen/js-mailer/response" - "github.com/wneessen/js-mailer/validation" - "net/http" - "strings" - "time" -) - -// SentSuccessfullJson represents a send confirmation JSON struct -type SentSuccessfullJson struct { - FormId string `json:"form_id"` - SendTime int64 `json:"send_time"` -} - -// SendFormParse parses the coming form data of a send requests and returns an error -// if data is missing or incorrect -func (a *ApiRequest) SendFormParse(r *http.Request) (int, error) { - urlParts := strings.SplitN(r.URL.String(), "/", 6) - if len(urlParts) != 6 { - return 404, fmt.Errorf("invalid URL") - } - a.FormId = urlParts[4] - a.Token = urlParts[5] - - // Only if the URL is syntatically correct, let's parse the body - if err := r.ParseMultipartForm(a.Config.Forms.MaxLength); err != nil { - return 500, err - } - return 0, nil -} - -// SendFormValidate validates that all requirement are fulfilled and returns an error -// if the validation failed -func (a *ApiRequest) SendFormValidate(r *http.Request) (int, error) { - l := log.WithFields(log.Fields{ - "action": "apiRequest.SendFormValidate", - }) - - // Let's retrieve the formObj from cache - var tokenRespObj TokenResponseJson - cacheObj, err := a.Cache.Get(a.Token) - if err == ttlcache.ErrNotFound { - return 404, fmt.Errorf("not a valid send URL") - } - if err != nil && err != ttlcache.ErrNotFound { - return 500, fmt.Errorf("failed to look up token in cache: %s", err) - } - if cacheObj != nil { - tokenRespObj = cacheObj.(TokenResponseJson) - } - if tokenRespObj.FormId != a.FormId { - l.Warn("URL form id does not match the cached form object id") - return 400, fmt.Errorf("invalid form id") - } - defer func() { - if err := a.Cache.Remove(a.Token); err != nil { - l.Errorf("Failed to delete used token from cache: %s", err) - } - }() - - // Let's try to read formobj from cache - formObj, err := a.GetForm(a.FormId) - if err != nil { - l.Errorf("Failed get formObj: %s", err) - return 500, fmt.Errorf("form lookup failed") - } - a.FormObj = &formObj - - // Make sure all required fields are set - // Maybe we can build some kind of validator here - var invalidFields []string - fieldError := make(map[string]string) - for _, f := range formObj.Validation.Fields { - if err := validation.Field(r, &f); err != nil { - invalidFields = append(invalidFields, f.Name) - fieldError[f.Name] = err.Error() - } - } - if len(invalidFields) > 0 { - l.Errorf("Form field validation failed: %s", strings.Join(invalidFields, ", ")) - var errorMsg []string - for _, f := range invalidFields { - errorMsg = append(errorMsg, fmt.Sprintf("%s: %s", f, fieldError[f])) - } - return 400, fmt.Errorf("field(s) validation failed: %s", strings.Join(errorMsg, ", ")) - } - - // Anti-SPAM honeypot handling - if formObj.Validation.Honeypot != nil { - if r.Form.Get(*formObj.Validation.Honeypot) != "" { - l.Warnf("Form includes a honeypot field which is not empty. Denying request") - return 400, fmt.Errorf("invalid form data") - } - } - - // Validate hCaptcha if enabled - if formObj.Validation.Hcaptcha.Enabled { - hcapResponse := r.Form.Get("h-captcha-response") - if hcapResponse == "" { - return 400, fmt.Errorf("missing hCaptcha response") - } - if ok := validation.Hcaptcha(hcapResponse, formObj.Validation.Hcaptcha.SecretKey); !ok { - return 400, fmt.Errorf("hCaptcha challenge-response validation failed") - } - } - - // Validate reCaptcha if enabled - if formObj.Validation.Recaptcha.Enabled { - recapResponse := r.Form.Get("g-recaptcha-response") - if recapResponse == "" { - return 400, fmt.Errorf("missing reCaptcha response") - } - if ok := validation.Recaptcha(recapResponse, formObj.Validation.Recaptcha.SecretKey); !ok { - return 400, fmt.Errorf("reCaptcha challenge-response validation failed") - } - } - - // Check the token - reqOrigin := r.Header.Get("origin") - if reqOrigin == "" { - l.Errorf("No origin domain set in HTTP request") - return 401, fmt.Errorf("domain not allowed to access form") - } - tokenText := fmt.Sprintf("%s_%d_%d_%s_%s", reqOrigin, tokenRespObj.CreateTime, tokenRespObj.ExpireTime, - formObj.Id, formObj.Secret) - tokenSha := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenText))) - if tokenSha != a.Token { - l.Errorf("No origin domain set in HTTP request") - return 401, fmt.Errorf("domain not allowed to access form") - } - - return 0, nil -} - -// SendForm handles a send Api request -func (a *ApiRequest) SendForm(w http.ResponseWriter, r *http.Request) { - l := log.WithFields(log.Fields{ - "action": "apiRequest.SendForm", - }) - - // Compose the mail message - mailMsg := mail.NewMessage() - mailMsg.SetHeader("From", a.FormObj.Sender) - mailMsg.SetHeader("To", a.FormObj.Recipients...) - mailMsg.SetHeader("Subject", a.FormObj.Content.Subject) - - mailBody := "The following form fields have been transmitted:\n" - for _, k := range a.FormObj.Content.Fields { - if v := r.Form.Get(k); v != "" { - mailBody = fmt.Sprintf("%s\n* %s => %s", mailBody, k, v) - } - } - mailMsg.SetBody("text/plain", mailBody) - - // Send the mail message - var serverTimeout time.Duration - var err error - serverTimeout, err = time.ParseDuration(a.FormObj.Server.Timeout) - if err != nil { - l.Warnf("Could not parse configured server timeout: %s", err) - serverTimeout = time.Second * 5 - } - mailDailer := mail.NewDialer(a.FormObj.Server.Host, a.FormObj.Server.Port, a.FormObj.Server.Username, - a.FormObj.Server.Password) - mailDailer.Timeout = serverTimeout - if a.FormObj.Server.ForceTLS { - mailDailer.StartTLSPolicy = mail.MandatoryStartTLS - } - mailSender, err := mailDailer.Dial() - if err != nil { - l.Errorf("Could not connect to configured mail server: %s", err) - response.ErrorJson(w, 500, err.Error()) - return - } - defer func() { - if err := mailSender.Close(); err != nil { - l.Errorf("Failed to close mail server connection: %s", err) - } - }() - if err := mail.Send(mailSender, mailMsg); err != nil { - l.Errorf("Could not send mail message: %s", err) - response.ErrorJson(w, 500, err.Error()) - return - } - - response.SuccessJson(w, 200, &SentSuccessfullJson{ - FormId: a.FormObj.Id, - SendTime: time.Now().Unix(), - }) -} diff --git a/config/config.go b/config/config.go index 2d8be08..378846b 100644 --- a/config/config.go +++ b/config/config.go @@ -6,24 +6,26 @@ import ( log "github.com/sirupsen/logrus" "os" "path/filepath" + "time" ) // Config represents the global config object struct type Config struct { Loglevel string `fig:"loglevel" default:"debug"` - Api struct { - Addr string `fig:"bind_addr"` - Port uint32 `fig:"port" default:"8765"` + Forms struct { + Path string `fig:"path" validate:"required"` } - Forms struct { - Path string `fig:"path" validate:"required"` - MaxLength int64 `fig:"maxlength" default:"1024000"` + Server struct { + Addr string `fig:"bind_addr"` + Port uint32 `fig:"port" default:"8765"` + Timeout time.Duration `fig:"timeout" default:"15s"` + RequestLimit string `fig:"request_limit" default:"10M"` } } // NewConfig returns a new Config object and fails if the configuration was not found or // has bad syntax -func NewConfig() Config { +func NewConfig() *Config { l := log.WithFields(log.Fields{ "action": "config.NewConfig", }) @@ -46,5 +48,5 @@ func NewConfig() Config { os.Exit(1) } - return confObj + return &confObj } diff --git a/form/form.go b/form/form.go index b956a5b..a884d30 100644 --- a/form/form.go +++ b/form/form.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/cyphar/filepath-securejoin" "github.com/kkyr/fig" - log "github.com/sirupsen/logrus" "github.com/wneessen/js-mailer/config" "os" ) @@ -46,30 +45,25 @@ type Form struct { type ValidationField struct { Name string `fig:"name" validate:"required"` Type string `fig:"type"` + Value string `fig:"value"` Required bool `fig:"required"` } // NewForm returns a new Form object to the caller. It fails with an error when // the form is question wasn't found or does not fulfill the syntax requirements func NewForm(c *config.Config, i string) (Form, error) { - l := log.WithFields(log.Fields{ - "action": "form.NewForm", - }) formPath, err := securejoin.SecureJoin(c.Forms.Path, fmt.Sprintf("%s.json", i)) if err != nil { - l.Errorf("Failed to securely join forms path and form id") - return Form{}, fmt.Errorf("not a valid form id") + return Form{}, fmt.Errorf("failed to securely join forms path and form id") } _, err = os.Stat(formPath) if err != nil { - l.Errorf("Failed to stat form config: %s", err) - return Form{}, fmt.Errorf("not a valid form id") + return Form{}, fmt.Errorf("failed to stat form config: %s", err) } var formObj Form if err := fig.Load(&formObj, fig.File(fmt.Sprintf("%s.json", i)), fig.Dirs(c.Forms.Path)); err != nil { - l.Errorf("Failed to read form config: %s", err) - return Form{}, fmt.Errorf("Not a valid form id") + return Form{}, fmt.Errorf("failed to read form config: %s", err) } return formObj, nil diff --git a/go.mod b/go.mod index 2a8ecee..cf478a2 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/cyphar/filepath-securejoin v0.2.3 github.com/go-mail/mail v2.3.1+incompatible github.com/kkyr/fig v0.2.0 + github.com/labstack/echo/v4 v4.5.0 + github.com/labstack/gommon v0.3.0 github.com/sirupsen/logrus v1.8.1 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/mail.v2 v2.3.1 // indirect diff --git a/go.sum b/go.sum index 3c4a2a8..f3822b5 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-mail/mail v2.3.1+incompatible h1:UzNOn0k5lpfVtO31cK3hn6I4VEVGhe3lX8AJBAxXExM= github.com/go-mail/mail v2.3.1+incompatible/go.mod h1:VPWjmmNyRsWXQZHVHT3g0YbIINUkSmuKOiLIDkWbL6M= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/kkyr/fig v0.2.0 h1:t/5yENaBw8ATXbQSWpPqwXLCn6wdhEi6jWXRfUgytZI= github.com/kkyr/fig v0.2.0/go.mod h1:iqSnedEGFSofGzaB8p34xOhX1ppE1kMulSmJLZ2tNnw= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -16,6 +18,17 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/labstack/echo/v4 v4.5.0 h1:JXk6H5PAw9I3GwizqUHhYyS4f45iyGebR/c1xNCeOCY= +github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y= +github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/pelletier/go-toml v1.6.0 h1:aetoXYr0Tv7xRU/V4B4IZJ2QcbtMUFoNb3ORp7TzIK4= @@ -29,12 +42,19 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= @@ -44,17 +64,32 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 h1:F5Gozwx4I1xtr/sr/8CFbb57iKi3297KFs0QDbGN60A= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/logging/logging.go b/logging/logging.go deleted file mode 100644 index 11faf41..0000000 --- a/logging/logging.go +++ /dev/null @@ -1,36 +0,0 @@ -package logging - -import ( - log "github.com/sirupsen/logrus" - "strings" -) - -// InitLogging initializes the logging object and sets some sane defaults -func InitLogging() { - log.SetLevel(log.InfoLevel) - log.SetFormatter(&log.TextFormatter{ - DisableLevelTruncation: true, - DisableColors: false, - FullTimestamp: true, - TimestampFormat: "2006-01-02 15:04:05 -0700", - }) -} - -// SetLogLevel sets the correspoding log level for the global logging object -func SetLogLevel(l string) { - if l == "" { - return - } - switch strings.ToLower(l) { - case "debug": - log.SetLevel(log.DebugLevel) - case "info": - log.SetLevel(log.InfoLevel) - case "warn": - log.SetLevel(log.WarnLevel) - case "error": - log.SetLevel(log.ErrorLevel) - default: - log.SetLevel(log.InfoLevel) - } -} diff --git a/main.go b/main.go index b6375b7..dbc3301 100644 --- a/main.go +++ b/main.go @@ -3,61 +3,27 @@ package main import ( "fmt" "github.com/ReneKroon/ttlcache/v2" - log "github.com/sirupsen/logrus" - "github.com/wneessen/js-mailer/apirequest" "github.com/wneessen/js-mailer/config" - "github.com/wneessen/js-mailer/logging" - "net/http" + "github.com/wneessen/js-mailer/server" "os" "time" ) -// VERSION is the global version string contstant -const VERSION = "0.1.4" - -// serve acts as main web service server muxer/handler for incoming HTTP requests -func serve(c *config.Config) { - l := log.WithFields(log.Fields{ - "action": "main.serve", - }) - l.Infof("Starting up js-mailer v%s server API on port %s:%d", VERSION, c.Api.Addr, c.Api.Port) +func main() { + confObj := config.NewConfig() - // Initialize the cache cacheObj := ttlcache.NewCache() - if err := cacheObj.SetTTL(time.Duration(10 * time.Minute)); err != nil { - l.Errorf("Failed to set TTL on cache object: %s", err) + if err := cacheObj.SetTTL(10 * time.Minute); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "ERROR: Failed to set default TTL on cache: %s", err) + os.Exit(1) } defer func() { - if err := cacheObj.Close(); err != nil { - l.Errorf("Failed to close cache object: %s", err) - } + _ = cacheObj.Close() }() - // Initialize the Api request object - apiReq := &apirequest.ApiRequest{ + srv := server.Srv{ Cache: cacheObj, - Config: c, - } - httpMux := http.NewServeMux() - httpMux.HandleFunc("/", apiReq.RequestHandler) - httpSrv := &http.Server{ - ReadTimeout: 5 * time.Second, - WriteTimeout: 5 * time.Second, - IdleTimeout: 15 * time.Second, - ReadHeaderTimeout: 5 * time.Second, - Handler: http.TimeoutHandler(httpMux, time.Second*15, ""), - Addr: fmt.Sprintf("%s:%d", c.Api.Addr, c.Api.Port), + Config: confObj, } - if err := httpSrv.ListenAndServe(); err != nil { - l.Errorf("Failed to start server: %s", err) - os.Exit(1) - } -} - -// main is the main function -func main() { - logging.InitLogging() - confObj := config.NewConfig() - logging.SetLogLevel(confObj.Loglevel) - serve(&confObj) + srv.Start() } diff --git a/response/error.go b/response/error.go index 6d1fd02..3d3393e 100644 --- a/response/error.go +++ b/response/error.go @@ -1,56 +1,45 @@ package response import ( - "encoding/json" - log "github.com/sirupsen/logrus" + "github.com/labstack/echo/v4" "net/http" ) -// HttpStatusMsg is a mapping of HTTP status codes to their corresponding status message -var HttpStatusMsg = map[int]string{ - 200: "Ok", - 400: "Bad Request", - 401: "Unauthorized", - 404: "Not Found", - 500: "Internal Server Error", +// ErrorObj is the structure of an error +type ErrorObj struct { + Message string + Data interface{} } -// ErrorResponseJson reflects the JSON response for a failed request -type ErrorResponseJson struct { +// ErrorResponse reflects the default JSON structure of an error response +type ErrorResponse struct { StatusCode int `json:"status_code"` Status string `json:"status"` ErrorMessage string `json:"error_message"` ErrorData interface{} `json:"error_data,omitempty"` } -// ErrorJson writes a ErrorResponseJson with no ErrorData to the http.ResponseWriter in case -// an error response is needed as result to the HTTP request -func ErrorJson(w http.ResponseWriter, c int, m string) { - errorJson(w, c, m, nil) -} - -// ErrorJsonData writes a ErrorResponseJson with ErrorData to the http.ResponseWriter in case -// an error response is needed as result to the HTTP request -func ErrorJsonData(w http.ResponseWriter, c int, m string, d interface{}) { - errorJson(w, c, m, d) -} - -// errorJson writes a ErrorResponseJson to the http.ResponseWriter in case -// an error response is needed as result to the HTTP request -func errorJson(w http.ResponseWriter, c int, m string, d interface{}) { - l := log.WithFields(log.Fields{ - "action": "http_error.ErrorJson", - }) - l.Debugf("Request failed with code %d (%s): %s", c, HttpStatusMsg[c], m) - errorJson := ErrorResponseJson{ - StatusCode: c, - Status: HttpStatusMsg[c], - ErrorMessage: m, - ErrorData: d, +// CustomError is a custom error handler for the echo.NewHTTPError function +func CustomError(err error, c echo.Context) { + errResp := &ErrorResponse{ + StatusCode: http.StatusInternalServerError, + } + if he, ok := err.(*echo.HTTPError); ok { + errResp.StatusCode = he.Code + errResp.Status = http.StatusText(errResp.StatusCode) + if em, ok := he.Message.(*echo.HTTPError); ok { + errResp.ErrorMessage = em.Message.(string) + } + if em, ok := he.Message.(*ErrorObj); ok { + errResp.ErrorMessage = em.Message + errResp.ErrorData = em.Data + } + if em, ok := he.Message.(string); ok { + errResp.ErrorMessage = em + } } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(c) - if err := json.NewEncoder(w).Encode(errorJson); err != nil { - l.Errorf("Failed to write error response JSON: %s", err) + + if err := c.JSON(errResp.StatusCode, errResp); err != nil { + c.Logger().Errorf("Failed to render error JSON: %s", err) } } diff --git a/response/success.go b/response/success.go index d08f00e..f2b4b8d 100644 --- a/response/success.go +++ b/response/success.go @@ -1,32 +1,8 @@ package response -import ( - "encoding/json" - log "github.com/sirupsen/logrus" - "net/http" -) - -// SuccessResponseJson reflects the HTTP response JSON for a successful request -type SuccessResponseJson struct { +// SuccessResponse reflects the default JSON structure of an error response +type SuccessResponse struct { StatusCode int `json:"status_code"` Status string `json:"status"` - Data interface{} `json:"data"` -} - -// SuccessJson writes a SuccessResponseJson struct to the http.ResponseWriter -func SuccessJson(w http.ResponseWriter, c int, d interface{}) { - l := log.WithFields(log.Fields{ - "action": "http_error.ErrorJson", - }) - l.Debug("Request successfully completed") - successMsg := SuccessResponseJson{ - StatusCode: c, - Status: HttpStatusMsg[c], - Data: d, - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(c) - if err := json.NewEncoder(w).Encode(successMsg); err != nil { - l.Errorf("Failed to write success response JSON: %s", err) - } + Data interface{} `json:"data,omitempty"` } diff --git a/server/router_api.go b/server/router_api.go new file mode 100644 index 0000000..1558719 --- /dev/null +++ b/server/router_api.go @@ -0,0 +1,22 @@ +package server + +import ( + "github.com/wneessen/js-mailer/api" +) + +// RouterApi registers the JSON API routes with echo +func (s *Srv) RouterApi() { + apiRoute := api.Route{ + Cache: s.Cache, + Config: s.Config, + } + ag := s.Echo.Group("/api/v1") + + // API routes + ag.Add("GET", "/ping", apiRoute.Ping) + ag.Add("GET", "/token", apiRoute.GetToken) + ag.Add("POST", "/token", apiRoute.GetToken) + ag.Add("POST", "/send/:fid/:token", apiRoute.SendForm, + apiRoute.SendFormBindForm, apiRoute.SendFormReqFields, apiRoute.SendFormHoneypot, + apiRoute.SendFormHcaptcha, apiRoute.SendFormRecaptcha, apiRoute.SendFormCheckToken) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..3b35ca1 --- /dev/null +++ b/server/server.go @@ -0,0 +1,90 @@ +package server + +import ( + "context" + "fmt" + "github.com/wneessen/js-mailer/response" + "net/http" + "os" + "os/signal" + "time" + + "github.com/ReneKroon/ttlcache/v2" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/labstack/gommon/log" + "github.com/wneessen/js-mailer/config" +) + +// VERSION is the global version string contstant +const VERSION = "0.2.0" + +// Srv represents the server object +type Srv struct { + Cache *ttlcache.Cache + Config *config.Config + Echo *echo.Echo +} + +// Start initalizes and starts the web service +func (s *Srv) Start() { + s.Echo = echo.New() + + // Settings + s.Echo.HideBanner = true + s.Echo.HidePort = true + s.Echo.Debug = s.Config.Loglevel == "debug" + s.Echo.Server.ReadTimeout = s.Config.Server.Timeout + s.Echo.Server.WriteTimeout = s.Config.Server.Timeout + s.Echo.IPExtractor = echo.ExtractIPFromRealIPHeader() + s.Echo.HTTPErrorHandler = response.CustomError + s.LogLevel() + + // Register routes + s.RouterApi() + + // Middlewares + s.Echo.Use(middleware.Recover()) + s.Echo.Use(middleware.Logger()) + s.Echo.Use(middleware.BodyLimit(s.Config.Server.RequestLimit)) + s.Echo.Use(middleware.CORS()) + + // Start server + go func() { + s.Echo.Logger.Infof("Starting js-mailer v%s on: %s", VERSION, + fmt.Sprintf("%s:%d", s.Config.Server.Addr, s.Config.Server.Port)) + err := s.Echo.Start(fmt.Sprintf("%s:%d", s.Config.Server.Addr, s.Config.Server.Port)) + if err != nil && err != http.ErrServerClosed { + s.Echo.Logger.Errorf("Failed to start up web service: %s", err) + os.Exit(1) + } + }() + + // Wait for interrupt signal to gracefully shut down the server with a timeout of 10 seconds. + // Use a buffered channel to avoid missing signals as recommended for signal.Notify + quitSig := make(chan os.Signal, 1) + signal.Notify(quitSig, os.Interrupt) + <-quitSig + shutdownCtx, ctxCancel := context.WithTimeout(context.Background(), time.Second*5) + defer ctxCancel() + if err := s.Echo.Shutdown(shutdownCtx); err != nil { + s.Echo.Logger.Errorf("Failed to shut down gracefully: %s", err) + os.Exit(1) + } +} + +// LogLevel sets the log level based on the given loglevel in the config object +func (s *Srv) LogLevel() { + switch s.Config.Loglevel { + case "debug": + s.Echo.Logger.SetLevel(log.DEBUG) + case "info": + s.Echo.Logger.SetLevel(log.INFO) + case "warn": + s.Echo.Logger.SetLevel(log.WARN) + case "error": + s.Echo.Logger.SetLevel(log.ERROR) + default: + s.Echo.Logger.SetLevel(log.INFO) + } +} diff --git a/validation/field.go b/validation/field.go deleted file mode 100644 index 7754229..0000000 --- a/validation/field.go +++ /dev/null @@ -1,61 +0,0 @@ -package validation - -import ( - "fmt" - log "github.com/sirupsen/logrus" - "github.com/wneessen/js-mailer/form" - "net/http" - "regexp" -) - -// Field validates the form field based on its configured type -func Field(r *http.Request, f *form.ValidationField) error { - l := log.WithFields(log.Fields{ - "action": "validation.Field", - "fieldName": f.Name, - }) - - if f.Required && r.Form.Get(f.Name) == "" { - l.Debugf("Form is missing required field: %s", f.Name) - return fmt.Errorf("field is required, but missing") - } - - switch f.Type { - case "text": - return nil - case "email": - mailRegExp, err := regexp.Compile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") - if err != nil { - l.Errorf("Failed to compile email comparison regexp: %s", err) - return nil - } - if !mailRegExp.Match([]byte(r.Form.Get(f.Name))) { - l.Debugf("Form field is expected to be of type email but does not match this requirementd: %s", f.Name) - return fmt.Errorf("field is expected to be of type email, but does not match") - } - case "number": - numRegExp, err := regexp.Compile("^[0-9]+$") - if err != nil { - l.Errorf("Failed to compile email comparison regexp: %s", err) - return nil - } - if !numRegExp.Match([]byte(r.Form.Get(f.Name))) { - l.Debugf("Form field is expected to be of type number but does not match this requirementd: %s", f.Name) - return fmt.Errorf("field is expected to be of type number, but does not match") - } - case "bool": - boolRegExp, err := regexp.Compile("^(?i)(true|false|0|1)$") - if err != nil { - l.Errorf("Failed to compile boolean comparison regexp: %s", err) - return nil - } - if !boolRegExp.Match([]byte(r.Form.Get(f.Name))) { - l.Debugf("Form field is expected to be of type boolean but does not match this requirementd: %s", f.Name) - return fmt.Errorf("field is expected to be of type bool, but does not match") - } - default: - return nil - } - - return nil -} diff --git a/validation/hcaptcha.go b/validation/hcaptcha.go deleted file mode 100644 index 81003a1..0000000 --- a/validation/hcaptcha.go +++ /dev/null @@ -1,51 +0,0 @@ -package validation - -import ( - "bytes" - "encoding/json" - log "github.com/sirupsen/logrus" - "net/http" - "net/url" -) - -// HcaptchaResponseJson reflect the API response from hCaptcha -type HcaptchaResponseJson struct { - Success bool `json:"success"` - ChallengeTimestamp string `json:"challenge_ts"` - Hostname string `json:"hostname"` -} - -// Hcaptcha validates the hCaptcha challenge against the hCaptcha API -func Hcaptcha(c, s string) bool { - l := log.WithFields(log.Fields{ - "action": "validation.Hcaptcha", - }) - - // Create a HTTP request - postData := url.Values{ - "response": {c}, - "secret": {s}, - } - httpResp, err := http.PostForm("https://hcaptcha.com/siteverify", postData) - if err != nil { - l.Errorf("an error occurred creating new HTTP POST request: %v", err) - return false - } - - var respBody bytes.Buffer - _, err = respBody.ReadFrom(httpResp.Body) - if err != nil { - l.Errorf("Failed to read response body: %s", err) - return false - } - if httpResp.StatusCode == http.StatusOK { - var hcapResp HcaptchaResponseJson - if err := json.Unmarshal(respBody.Bytes(), &hcapResp); err != nil { - l.Errorf("Failed to unmarshal response JSON: %s", err) - return false - } - return hcapResp.Success - } - - return false -} diff --git a/validation/recaptcha.go b/validation/recaptcha.go deleted file mode 100644 index 4bf3a8b..0000000 --- a/validation/recaptcha.go +++ /dev/null @@ -1,51 +0,0 @@ -package validation - -import ( - "bytes" - "encoding/json" - log "github.com/sirupsen/logrus" - "net/http" - "net/url" -) - -// RecaptchaResponseJson reflect the API response from hCaptcha -type RecaptchaResponseJson struct { - Success bool `json:"success"` - ChallengeTimestamp string `json:"challenge_ts"` - Hostname string `json:"hostname"` -} - -// Recaptcha validates the reCaptcha challenge against the Google API -func Recaptcha(c, s string) bool { - l := log.WithFields(log.Fields{ - "action": "validation.Recaptcha", - }) - - // Create a HTTP request - postData := url.Values{ - "response": {c}, - "secret": {s}, - } - httpResp, err := http.PostForm("https://www.google.com/recaptcha/api/siteverify", postData) - if err != nil { - l.Errorf("an error occurred creating new HTTP POST request: %v", err) - return false - } - - var respBody bytes.Buffer - _, err = respBody.ReadFrom(httpResp.Body) - if err != nil { - l.Errorf("Failed to read response body: %s", err) - return false - } - if httpResp.StatusCode == http.StatusOK { - var recapResp RecaptchaResponseJson - if err := json.Unmarshal(respBody.Bytes(), &recapResp); err != nil { - l.Errorf("Failed to unmarshal response JSON: %s", err) - return false - } - return recapResp.Success - } - - return false -} From 3c4e96ce26b9383e14ce4f0ea6779b564ea22c4d Mon Sep 17 00:00:00 2001 From: Winni Neessen Date: Sat, 4 Sep 2021 16:22:21 +0200 Subject: [PATCH 2/2] Remove unused error loop in sendform_mw.go --- api/sendform_mw.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/sendform_mw.go b/api/sendform_mw.go index 4d4e0f1..92c2a14 100644 --- a/api/sendform_mw.go +++ b/api/sendform_mw.go @@ -157,10 +157,6 @@ func (r *Route) SendFormReqFields(next echo.HandlerFunc) echo.HandlerFunc { } if len(invalidFields) > 0 { c.Logger().Errorf("Form field validation failed: %s", strings.Join(invalidFields, ", ")) - var errorMsg []string - for _, f := range invalidFields { - errorMsg = append(errorMsg, fmt.Sprintf("%s: %s", f, fieldError[f])) - } return echo.NewHTTPError(http.StatusBadRequest, &response.ErrorObj{ Message: "fields(s) validation failed", Data: fieldError,