diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1eff657 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +bin/server: cmd/server/main.go pkg/*/*.go go.mod go.sum + go build -o bin/server cmd/server/main.go + +build: bin/server diff --git a/bin/server b/bin/server new file mode 100755 index 0000000..1921404 Binary files /dev/null and b/bin/server differ diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..c591f8e --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "netoik.io/netoik-website/pkg/captcha" + "netoik.io/netoik-website/pkg/conf" + "netoik.io/netoik-website/pkg/contact" + "os" +) + +func main() { + // Parse command line arguments + path := flag.String("c", "server.conf", "Config file") + flag.Parse() + + // Parse config file + if !conf.ParseFile(*path) { + os.Exit(1) + } + + // Setup captcha + captcha.Setup() + + // Declare api routes + http.HandleFunc("/api/contact/send", contact.HandleSend) + http.HandleFunc("/api/captcha/new", captcha.HandleNew) + + // Start listening + if err := http.ListenAndServe(fmt.Sprintf("%s:%d", conf.Conf.BindHost, conf.Conf.BindPort), nil); err != nil { + fmt.Fprintf(os.Stderr, "cannot listen at %s:%d: %s\n", conf.Conf.BindHost, conf.Conf.BindPort, err.Error()) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9cfb7a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module netoik.io/netoik-website + +go 1.18 + +require ( + github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e2d36b8 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f h1:q/DpyjJjZs94bziQ7YkBmIlpqbVP7yw179rnzoNVX1M= +github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f/go.mod h1:QGrK8vMWWHQYQ3QU9bw9Y9OPNfxccGzfb41qjvVeXtY= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= diff --git a/pkg/api/api.go b/pkg/api/api.go new file mode 100644 index 0000000..6a76c5f --- /dev/null +++ b/pkg/api/api.go @@ -0,0 +1,18 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +type Answer struct { + Success bool `json:"success"` + Id string `json:"id,omitempty"` +} + +func Reply(w http.ResponseWriter, code int, answer Answer) { + a, _ := json.Marshal(answer) + w.Header().Set(http.CanonicalHeaderKey("content-type"), "application/json") + w.WriteHeader(code) + w.Write(a) +} diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go new file mode 100644 index 0000000..6fb752b --- /dev/null +++ b/pkg/captcha/captcha.go @@ -0,0 +1,60 @@ +package captcha + +import ( + "errors" + "fmt" + "github.com/dchest/captcha" + "net/http" + "netoik.io/netoik-website/pkg/api" + "netoik.io/netoik-website/pkg/conf" + "os" + "path/filepath" + "time" +) + +func writeImage(id string) bool { + path := filepath.Join(conf.Conf.CaptchaDirectory, id+".png") + file, err := os.Create(path) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: cannot write file %q: %s\n", path, err.Error()) + return false + } + if err = captcha.WriteImage(file, id, conf.Conf.CaptchaWidth, conf.Conf.CaptchaHeight); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: cannot write captcha into file %q: %s\n", path, err.Error()) + return false + } + return true +} + +func HandleNew(w http.ResponseWriter, r *http.Request) { + // Check method + if r.Method != "POST" { + api.Reply(w, 405, api.Answer{}) + return + } + + // Create new captcha + id := captcha.NewLen(conf.Conf.CaptchaLength) + + // Write captcha image + if !writeImage(id) { + api.Reply(w, 500, api.Answer{}) + return + } + + // Remove captcha image after expiration time + go func(id string) { + time.Sleep(conf.Conf.CaptchaExpiration) + path := filepath.Join(conf.Conf.CaptchaDirectory, id+".png") + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(os.Stderr, "ERROR: cannot remove captcha image after expiration %q: %s", path, err.Error()) + } + }(id) + + // Return captcha id + api.Reply(w, 200, api.Answer{Success: true, Id: id}) +} + +func Setup() { + captcha.SetCustomStore(captcha.NewMemoryStore(captcha.CollectNum, conf.Conf.CaptchaExpiration)) +} diff --git a/pkg/conf/conf.go b/pkg/conf/conf.go new file mode 100644 index 0000000..e6bba03 --- /dev/null +++ b/pkg/conf/conf.go @@ -0,0 +1,108 @@ +package conf + +import ( + "fmt" + "github.com/pelletier/go-toml" + "net" + "os" + "time" +) + +type conf struct { + BindHost string `toml:"bind_host"` + BindPort int `toml:"bind_port"` + + SMTPHost string `toml:"smtp_host"` + SMTPPort int `toml:"smtp_port"` + SMTPUsername string `toml:"smtp_username"` + SMTPPassword string `toml:"smtp_password"` + SMTPReceiver string `toml:"smtp_receiver"` + + CaptchaDirectory string `toml:"captcha_directory"` + CaptchaLength int `toml:"captcha_length"` + CaptchaWidth int `toml:"captcha_width"` + CaptchaHeight int `toml:"captcha_height"` + CaptchaExpiration time.Duration `toml:"captcha_expiration"` +} + +var Conf = conf{ + BindHost: "127.0.0.1", + BindPort: 8000, + CaptchaLength: 6, + CaptchaWidth: 240, + CaptchaHeight: 80, + CaptchaExpiration: time.Hour, +} + +func ParseFile(path string) bool { + var failure bool + + // Open config file + file, err := os.Open(path) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: cannot open config file %q: %s\n", path, err.Error()) + return false + } + + // Read data from config file + var data = make([]byte, 10000) + n, err := file.Read(data) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: cannot read config file %q: %s\n", path, err.Error()) + return false + } + + // Parse toml from config file + if err = toml.Unmarshal(data[:n], &Conf); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: cannot load toml config from file %q: %s\n", path, err.Error()) + return false + } + + // Check arguments in config file + if net.ParseIP(Conf.BindHost) == nil { + fmt.Fprintf(os.Stderr, "ERROR: bad value for 'bind_host' in config file %q: should be a valid IP address\n", path) + failure = true + } + if Conf.BindPort < 1 || Conf.BindPort > 65535 { + fmt.Fprintf(os.Stderr, "ERROR: bad value for 'bind_port' in config file %q: should be an integer in 1-65535\n", path) + failure = true + } + if Conf.SMTPHost == "" { + fmt.Fprintf(os.Stderr, "ERROR: missing value for 'smtp_host' in config file %q\n", path) + failure = true + } + if Conf.SMTPPort < 1 || Conf.SMTPPort > 65535 { + fmt.Fprintf(os.Stderr, "ERROR: bad value for 'smtp_port' in config file %q: should be an integer in 1-65535\n", path) + failure = true + } + if Conf.SMTPUsername == "" { + fmt.Fprintf(os.Stderr, "ERROR: missing value for 'smtp_username' in config file %q\n", path) + failure = true + } + if Conf.SMTPPassword == "" { + fmt.Fprintf(os.Stderr, "ERROR: missing value for 'smtp_password' in config file %q\n", path) + failure = true + } + if Conf.SMTPReceiver == "" { + fmt.Fprintf(os.Stderr, "ERROR: missing value for 'smtp_receiver' in config file %q\n", path) + failure = true + } + if Conf.CaptchaDirectory == "" { + fmt.Fprintf(os.Stderr, "ERROR: missing value for 'captcha_directory' in config file %q\n", path) + failure = true + } + if Conf.CaptchaLength == 0 { + fmt.Fprintf(os.Stderr, "ERROR: missing value for 'captcha_length' in config file %q\n", path) + failure = true + } + if Conf.CaptchaWidth == 0 { + fmt.Fprintf(os.Stderr, "ERROR: missing value for 'captcha_width' in config file %q\n", path) + failure = true + } + if Conf.CaptchaHeight == 0 { + fmt.Fprintf(os.Stderr, "ERROR: missing value for 'captcha_height' in config file %q\n", path) + failure = true + } + + return !failure +} diff --git a/pkg/contact/contact.go b/pkg/contact/contact.go new file mode 100644 index 0000000..49dc240 --- /dev/null +++ b/pkg/contact/contact.go @@ -0,0 +1,97 @@ +package contact + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/dchest/captcha" + "gopkg.in/gomail.v2" + "net/http" + "netoik.io/netoik-website/pkg/api" + "netoik.io/netoik-website/pkg/conf" + "os" + "path/filepath" +) + +type request struct { + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Company string `json:"company"` + Message string `json:"message"` + CaptchaId string `json:"captchaId"` + CaptchaDigits string `json:"captchaDigits"` +} + +func HandleSend(w http.ResponseWriter, r *http.Request) { + // Check method + if r.Method != "POST" { + api.Reply(w, 405, api.Answer{}) + return + } + + // Parse json from request body + var data request + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + api.Reply(w, 400, api.Answer{}) + return + } + if data.Name == "" || len(data.Name) > 200 { + api.Reply(w, 400, api.Answer{}) + return + } + if data.Email == "" || len(data.Email) > 200 { + api.Reply(w, 400, api.Answer{}) + return + } + if len(data.Phone) > 200 { + api.Reply(w, 400, api.Answer{}) + return + } + if len(data.Company) > 200 { + api.Reply(w, 400, api.Answer{}) + return + } + if data.Message == "" || len(data.Message) > 10000 { + api.Reply(w, 400, api.Answer{}) + return + } + if data.CaptchaId == "" { + api.Reply(w, 400, api.Answer{}) + return + } + if data.CaptchaDigits == "" { + api.Reply(w, 400, api.Answer{}) + return + } + + // Check captcha digits + if !captcha.VerifyString(data.CaptchaId, data.CaptchaDigits) { + api.Reply(w, 418, api.Answer{}) + return + } + + // Captcha has been verified, so remove image + path := filepath.Join(conf.Conf.CaptchaDirectory, data.CaptchaId+".png") + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(os.Stderr, "ERROR: cannot remove captcha image %q: %s", path, err.Error()) + } + + // Build email + msg := gomail.NewMessage() + msg.SetHeader("From", conf.Conf.SMTPUsername) + msg.SetHeader("To", conf.Conf.SMTPReceiver) + msg.SetHeader("Subject", "Message from www.netoik.io") + msg.SetBody("text/plain", fmt.Sprintf( + "You have received a message from frontend.\nname: %s\nemail: %s\nphone: %s\ncompany: %s\n%s", + data.Name, data.Email, data.Phone, data.Company, data.Message)) + + // Configure SMTP dialer and send email + dialer := gomail.NewDialer(conf.Conf.SMTPHost, conf.Conf.SMTPPort, conf.Conf.SMTPUsername, conf.Conf.SMTPPassword) + if err := dialer.DialAndSend(msg); err != nil { + fmt.Fprintf(os.Stderr, "cannot send email: %s\n", err.Error()) + api.Reply(w, 400, api.Answer{}) + return + } + api.Reply(w, 200, api.Answer{Success: true}) +}