Merge branch 'develop' into TM-309
This commit is contained in:
+7
-5
@@ -107,6 +107,7 @@ import (
|
|||||||
"tm/internal/tender"
|
"tm/internal/tender"
|
||||||
"tm/internal/tender_approval"
|
"tm/internal/tender_approval"
|
||||||
"tm/internal/user"
|
"tm/internal/user"
|
||||||
|
"tm/pkg/security"
|
||||||
|
|
||||||
"tm/cmd/web/bootstrap"
|
"tm/cmd/web/bootstrap"
|
||||||
_ "tm/cmd/web/docs" // This is generated by swag
|
_ "tm/cmd/web/docs" // This is generated by swag
|
||||||
@@ -143,6 +144,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize notification service
|
// Initialize notification service
|
||||||
notificationSDK := bootstrap.InitNotificationService(conf.Notification, logger)
|
notificationSDK := bootstrap.InitNotificationService(conf.Notification, logger)
|
||||||
|
xssPolicy := security.NewXSSPolicy()
|
||||||
|
|
||||||
// Initialize hCaptcha verifier
|
// Initialize hCaptcha verifier
|
||||||
hcaptchaVerifier := bootstrap.InitHCaptchaService(conf.HCaptcha, logger)
|
hcaptchaVerifier := bootstrap.InitHCaptchaService(conf.HCaptcha, logger)
|
||||||
@@ -178,10 +180,10 @@ func main() {
|
|||||||
tenderService := tender.NewService(tenderRepository, companyService, logger)
|
tenderService := tender.NewService(tenderRepository, companyService, logger)
|
||||||
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
||||||
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
||||||
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger)
|
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy)
|
||||||
flagService := assets.NewService(logger, conf.Assets.FlagsPath)
|
flagService := assets.NewService(logger, conf.Assets.FlagsPath)
|
||||||
notificationService := notification.NewService(notificationSDK, userService, customerService, logger)
|
notificationService := notification.NewService(notificationSDK, userService, customerService, logger)
|
||||||
contactService := contact.NewService(contactRepository, logger)
|
contactService := contact.NewService(contactRepository, logger, notificationSDK)
|
||||||
cmsService := cms.NewService(cmsRepository, logger)
|
cmsService := cms.NewService(cmsRepository, logger)
|
||||||
scraperService := scraper.NewService(conf.Scraper.BaseURL, conf.Scraper.Timeout, logger, conf.Scraper.Username, conf.Scraper.Password, conf.Scraper.LoginURL)
|
scraperService := scraper.NewService(conf.Scraper.BaseURL, conf.Scraper.Timeout, logger, conf.Scraper.Username, conf.Scraper.Password, conf.Scraper.LoginURL)
|
||||||
logger.Info("Services initialized successfully", map[string]interface{}{
|
logger.Info("Services initialized successfully", map[string]interface{}{
|
||||||
@@ -196,7 +198,7 @@ func main() {
|
|||||||
tenderHandler := tender.NewHandler(tenderService, logger)
|
tenderHandler := tender.NewHandler(tenderService, logger)
|
||||||
feedbackHandler := feedback.NewHandler(feedbackService, userHandler, customerHandler, logger)
|
feedbackHandler := feedback.NewHandler(feedbackService, userHandler, customerHandler, logger)
|
||||||
tenderApprovalHandler := tender_approval.NewHandler(tenderApprovalService, logger)
|
tenderApprovalHandler := tender_approval.NewHandler(tenderApprovalService, logger)
|
||||||
inquiryHandler := inquiry.NewHandler(inquiryService, logger, hcaptchaVerifier)
|
inquiryHandler := inquiry.NewHandler(inquiryService, logger, hcaptchaVerifier, xssPolicy)
|
||||||
flagHandler := assets.NewHandler(flagService, logger)
|
flagHandler := assets.NewHandler(flagService, logger)
|
||||||
notificationHandler := notification.NewHandler(notificationService)
|
notificationHandler := notification.NewHandler(notificationService)
|
||||||
contactHandler := contact.NewHandler(contactService, hcaptchaVerifier)
|
contactHandler := contact.NewHandler(contactService, hcaptchaVerifier)
|
||||||
@@ -210,8 +212,8 @@ func main() {
|
|||||||
e := bootstrap.InitHTTPServer(conf, logger)
|
e := bootstrap.InitHTTPServer(conf, logger)
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, scraperHandler)
|
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, scraperHandler, xssPolicy)
|
||||||
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler)
|
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, xssPolicy)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)
|
serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)
|
||||||
|
|||||||
@@ -14,13 +14,23 @@ import (
|
|||||||
"tm/internal/tender"
|
"tm/internal/tender"
|
||||||
"tm/internal/tender_approval"
|
"tm/internal/tender_approval"
|
||||||
"tm/internal/user"
|
"tm/internal/user"
|
||||||
|
"tm/pkg/security"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, scraperHandler *scraper.Handler) {
|
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, scraperHandler *scraper.Handler, xssPolicy *security.XSSPolicy) {
|
||||||
adminV1 := e.Group("/admin/v1")
|
adminV1 := e.Group("/admin/v1")
|
||||||
|
|
||||||
|
// Add CSP middleware to admin routes (stricter policy)
|
||||||
|
adminCSPConfig := security.DefaultCSPConfig()
|
||||||
|
adminCSPConfig.ScriptSrc = []string{"'self'"} // No unsafe-inline for admin
|
||||||
|
adminCSPConfig.StyleSrc = []string{"'self'"} // No unsafe-inline for admin
|
||||||
|
e.Use(security.CSPMiddleware(adminCSPConfig))
|
||||||
|
|
||||||
|
// Add CSP report endpoint
|
||||||
|
e.POST("/api/v1/csp-report", security.CSPReportHandler)
|
||||||
|
|
||||||
// Admin Users Routes
|
// Admin Users Routes
|
||||||
adminUsersGP := adminV1.Group("/users")
|
adminUsersGP := adminV1.Group("/users")
|
||||||
{
|
{
|
||||||
@@ -175,9 +185,16 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler) {
|
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, xssPolicy *security.XSSPolicy) {
|
||||||
v1 := e.Group("/api/v1")
|
v1 := e.Group("/api/v1")
|
||||||
|
|
||||||
|
// Add CSP middleware to public routes
|
||||||
|
cspConfig := security.DefaultCSPConfig()
|
||||||
|
e.Use(security.CSPMiddleware(cspConfig))
|
||||||
|
|
||||||
|
// Add CSP report endpoint
|
||||||
|
e.POST("/api/v1/csp-report", security.CSPReportHandler)
|
||||||
|
|
||||||
customerGP := v1.Group("/profile")
|
customerGP := v1.Group("/profile")
|
||||||
{
|
{
|
||||||
customerGP.POST("/login", customerHandler.Login)
|
customerGP.POST("/login", customerHandler.Login)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ toolchain go1.24.4
|
|||||||
require (
|
require (
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/labstack/echo/v4 v4.13.4
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/minio/minio-go/v7 v7.0.97
|
github.com/minio/minio-go/v7 v7.0.97
|
||||||
github.com/redis/go-redis/v9 v9.14.0
|
github.com/redis/go-redis/v9 v9.14.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
@@ -21,6 +22,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
@@ -32,6 +34,7 @@ require (
|
|||||||
github.com/go-openapi/spec v0.21.0 // indirect
|
github.com/go-openapi/spec v0.21.0 // indirect
|
||||||
github.com/go-openapi/swag v0.23.1 // indirect
|
github.com/go-openapi/swag v0.23.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
|
|||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
@@ -34,6 +36,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
@@ -57,6 +61,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
||||||
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package contact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContactEmailTemplateData represents the data needed for contact confirmation email
|
||||||
|
type ContactEmailTemplateData struct {
|
||||||
|
FullName string
|
||||||
|
Email string
|
||||||
|
Phone string
|
||||||
|
Message string
|
||||||
|
ContactID string
|
||||||
|
SubmittedAt int64
|
||||||
|
CompanyName string
|
||||||
|
CompanyURL string
|
||||||
|
SupportEmail string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateContactConfirmationEmailTemplate generates a beautiful HTML email template for contact confirmation
|
||||||
|
func GenerateContactConfirmationEmailTemplate(data *ContactEmailTemplateData) string {
|
||||||
|
// Format the submission date
|
||||||
|
submittedAt := time.Unix(data.SubmittedAt, 0).Format("January 2, 2006 at 3:04 PM")
|
||||||
|
|
||||||
|
html := `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Contact Confirmation - ` + data.CompanyName + `</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 16px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-card {
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
border: 2px solid #0ea5e9;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
margin: 30px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-card::before {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
top: -15px;
|
||||||
|
left: 20px;
|
||||||
|
background-color: #0ea5e9;
|
||||||
|
color: white;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
color: #0c4a6e;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-details {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-left: 4px solid #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
min-width: 120px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: #6b7280;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-preview {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 15px;
|
||||||
|
border-left: 3px solid #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-preview strong {
|
||||||
|
color: #374151;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info {
|
||||||
|
background-color: #fffbeb;
|
||||||
|
border: 1px solid #fbbf24;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info h4 {
|
||||||
|
color: #92400e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info p {
|
||||||
|
color: #78350f;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<h1>Thank You for Contacting Us!</h1>
|
||||||
|
<p>Your message has been received and we'll get back to you soon.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="content">
|
||||||
|
<div class="confirmation-card">
|
||||||
|
<div class="success-message">
|
||||||
|
✓ Your contact form has been submitted successfully!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Full Name:</span>
|
||||||
|
<span class="detail-value">{{FULL_NAME}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Email:</span>
|
||||||
|
<span class="detail-value">{{EMAIL}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Phone:</span>
|
||||||
|
<span class="detail-value">{{PHONE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Reference ID:</span>
|
||||||
|
<span class="detail-value">{{CONTACT_ID}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-preview">
|
||||||
|
<strong>Your Message:</strong>
|
||||||
|
{{MESSAGE}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timestamp">
|
||||||
|
<strong>Submitted on:</strong> {{SUBMITTED_AT}}
|
||||||
|
<br>
|
||||||
|
<p>This email confirms your submission to ` + data.CompanyName + `</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="support-info">
|
||||||
|
<h4>Need Immediate Assistance?</h4>
|
||||||
|
<p><strong>Email:</strong> <a href="mailto:{{SUPPORT_EMAIL}}">{{SUPPORT_EMAIL}}</a></p>
|
||||||
|
<p><strong>Website:</strong> <a href="{{COMPANY_URL}}" target="_blank">{{COMPANY_URL}}</a></p>
|
||||||
|
<p>Our team typically responds within 24 hours during business days.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated confirmation email from ` + data.CompanyName + `</p>
|
||||||
|
<p>Please do not reply to this email. For questions, contact us at <a href="mailto:{{SUPPORT_EMAIL}}">{{SUPPORT_EMAIL}}</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
// Replace placeholders with actual data
|
||||||
|
html = strings.ReplaceAll(html, "{{COMPANY_NAME}}", data.CompanyName)
|
||||||
|
html = strings.ReplaceAll(html, "{{FULL_NAME}}", data.FullName)
|
||||||
|
html = strings.ReplaceAll(html, "{{EMAIL}}", data.Email)
|
||||||
|
html = strings.ReplaceAll(html, "{{PHONE}}", data.Phone)
|
||||||
|
html = strings.ReplaceAll(html, "{{CONTACT_ID}}", data.ContactID)
|
||||||
|
html = strings.ReplaceAll(html, "{{MESSAGE}}", data.Message)
|
||||||
|
html = strings.ReplaceAll(html, "{{SUBMITTED_AT}}", submittedAt)
|
||||||
|
html = strings.ReplaceAll(html, "{{COMPANY_URL}}", data.CompanyURL)
|
||||||
|
html = strings.ReplaceAll(html, "{{SUPPORT_EMAIL}}", data.SupportEmail)
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateContactConfirmationEmailMessage generates a simple text message for the email
|
||||||
|
func GenerateContactConfirmationEmailMessage(data *ContactEmailTemplateData) string {
|
||||||
|
submittedAt := time.Unix(data.SubmittedAt, 0).Format("January 2, 2006 at 3:04 PM")
|
||||||
|
|
||||||
|
return fmt.Sprintf(`
|
||||||
|
Contact Form Submission Confirmation
|
||||||
|
|
||||||
|
Thank you for contacting us! Your message has been received successfully.
|
||||||
|
|
||||||
|
Contact Details:
|
||||||
|
- Full Name: %s
|
||||||
|
- Email: %s
|
||||||
|
- Phone: %s
|
||||||
|
- Reference ID: %s
|
||||||
|
- Submitted on: %s
|
||||||
|
|
||||||
|
Your Message:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Our team will review your inquiry and get back to you within 24 hours during business days.
|
||||||
|
|
||||||
|
For urgent matters, please contact us directly:
|
||||||
|
Email: %s
|
||||||
|
Website: %s
|
||||||
|
|
||||||
|
This is an automated confirmation email from %s.
|
||||||
|
Please do not reply to this email.
|
||||||
|
`,
|
||||||
|
data.FullName,
|
||||||
|
data.Email,
|
||||||
|
data.Phone,
|
||||||
|
data.ContactID,
|
||||||
|
submittedAt,
|
||||||
|
data.Message,
|
||||||
|
data.SupportEmail,
|
||||||
|
data.CompanyURL,
|
||||||
|
data.CompanyName,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
orm "tm/pkg/mongo"
|
orm "tm/pkg/mongo"
|
||||||
|
"tm/pkg/notification"
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,13 +34,15 @@ type Service interface {
|
|||||||
type contactService struct {
|
type contactService struct {
|
||||||
repo ContactRepository
|
repo ContactRepository
|
||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
|
sdk notification.SDK
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new contact service
|
// NewService creates a new contact service
|
||||||
func NewService(repo ContactRepository, logger logger.Logger) Service {
|
func NewService(repo ContactRepository, logger logger.Logger, sdk notification.SDK) Service {
|
||||||
return &contactService{
|
return &contactService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
sdk: sdk,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +71,52 @@ func (s *contactService) Create(ctx context.Context, form *CreateContactForm) (*
|
|||||||
return nil, fmt.Errorf("failed to create contact: %w", err)
|
return nil, fmt.Errorf("failed to create contact: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send confirmation email
|
||||||
|
s.sdk.SendNotification(ctx, ¬ification.NotificationRequest{
|
||||||
|
UserID: contact.GetID(),
|
||||||
|
Title: "Contact Form Submission Confirmation",
|
||||||
|
Message: GenerateContactConfirmationEmailTemplate(&ContactEmailTemplateData{
|
||||||
|
FullName: contact.FullName,
|
||||||
|
Email: contact.Email,
|
||||||
|
Phone: contact.Phone,
|
||||||
|
Message: contact.Message,
|
||||||
|
ContactID: contact.GetID(),
|
||||||
|
SubmittedAt: contact.CreatedAt,
|
||||||
|
CompanyName: "Opplens",
|
||||||
|
CompanyURL: "https://opplens.com",
|
||||||
|
SupportEmail: "info@opplens.com",
|
||||||
|
}),
|
||||||
|
Type: "confirmation",
|
||||||
|
Priority: "normal",
|
||||||
|
EventType: notification.EventTypeEmail,
|
||||||
|
Methods: notification.NotificationMethods{
|
||||||
|
Email: contact.Email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify admin/internal team
|
||||||
|
s.sdk.SendNotification(ctx, ¬ification.NotificationRequest{
|
||||||
|
UserID: contact.GetID(),
|
||||||
|
Title: fmt.Sprintf("New Contact Form Submission from %s", contact.FullName),
|
||||||
|
Message: GenerateContactConfirmationEmailTemplate(&ContactEmailTemplateData{
|
||||||
|
FullName: contact.FullName,
|
||||||
|
Email: contact.Email,
|
||||||
|
Phone: contact.Phone,
|
||||||
|
Message: contact.Message,
|
||||||
|
ContactID: contact.GetID(),
|
||||||
|
SubmittedAt: contact.CreatedAt,
|
||||||
|
CompanyName: "Opplens",
|
||||||
|
CompanyURL: "https://opplens.com",
|
||||||
|
SupportEmail: "info@opplens.com",
|
||||||
|
}),
|
||||||
|
Type: "alert",
|
||||||
|
Priority: "normal",
|
||||||
|
EventType: notification.EventTypeEmail,
|
||||||
|
Methods: notification.NotificationMethods{
|
||||||
|
Email: "info@opplens.com",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
s.logger.Info("Contact message created successfully", map[string]interface{}{
|
s.logger.Info("Contact message created successfully", map[string]interface{}{
|
||||||
"id": contact.GetID(),
|
"id": contact.GetID(),
|
||||||
"email": form.Email,
|
"email": form.Email,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package inquiry
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"tm/pkg/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailTemplateData represents the data needed for the email template
|
// EmailTemplateData represents the data needed for the email template
|
||||||
@@ -20,7 +23,14 @@ type EmailTemplateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GenerateNewInquiryEmailTemplate generates a beautiful HTML email template for new inquiry notifications
|
// GenerateNewInquiryEmailTemplate generates a beautiful HTML email template for new inquiry notifications
|
||||||
func GenerateNewInquiryEmailTemplate(data *EmailTemplateData) string {
|
func GenerateNewInquiryEmailTemplate(data *EmailTemplateData, xssPolicy *security.XSSPolicy) string {
|
||||||
|
// Sanitize all user inputs before inserting into HTML
|
||||||
|
safeFullName := html.EscapeString(data.FullName)
|
||||||
|
safeCompanyName := html.EscapeString(data.CompanyName)
|
||||||
|
safeWorkEmail := html.EscapeString(data.WorkEmail)
|
||||||
|
safePhoneNumber := html.EscapeString(data.PhoneNumber)
|
||||||
|
safeInquiryID := html.EscapeString(data.InquiryID)
|
||||||
|
|
||||||
// Format the creation date
|
// Format the creation date
|
||||||
createdAt := time.Unix(data.CreatedAt, 0).Format("January 2, 2006 at 3:04 PM")
|
createdAt := time.Unix(data.CreatedAt, 0).Format("January 2, 2006 at 3:04 PM")
|
||||||
|
|
||||||
@@ -249,12 +259,12 @@ func GenerateNewInquiryEmailTemplate(data *EmailTemplateData) string {
|
|||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
||||||
// Replace placeholders with actual data
|
// Replace placeholders with sanitized data
|
||||||
html = strings.ReplaceAll(html, "{{COMPANY_NAME}}", data.CompanyName)
|
html = strings.ReplaceAll(html, "{{COMPANY_NAME}}", safeCompanyName)
|
||||||
html = strings.ReplaceAll(html, "{{INQUIRY_ID}}", data.InquiryID)
|
html = strings.ReplaceAll(html, "{{INQUIRY_ID}}", safeInquiryID)
|
||||||
html = strings.ReplaceAll(html, "{{FULL_NAME}}", data.FullName)
|
html = strings.ReplaceAll(html, "{{FULL_NAME}}", safeFullName)
|
||||||
html = strings.ReplaceAll(html, "{{WORK_EMAIL}}", data.WorkEmail)
|
html = strings.ReplaceAll(html, "{{WORK_EMAIL}}", safeWorkEmail)
|
||||||
html = strings.ReplaceAll(html, "{{PHONE_NUMBER}}", data.PhoneNumber)
|
html = strings.ReplaceAll(html, "{{PHONE_NUMBER}}", safePhoneNumber)
|
||||||
html = strings.ReplaceAll(html, "{{CREATED_AT}}", createdAt)
|
html = strings.ReplaceAll(html, "{{CREATED_AT}}", createdAt)
|
||||||
|
|
||||||
return html
|
return html
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package inquiry
|
package inquiry
|
||||||
|
|
||||||
import "tm/pkg/response"
|
import (
|
||||||
|
"tm/pkg/response"
|
||||||
|
"tm/pkg/security"
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
|
)
|
||||||
|
|
||||||
// Inquiry DTOs
|
// Inquiry DTOs
|
||||||
|
|
||||||
@@ -84,3 +89,72 @@ func (i *Inquiry) ToResponse() *InquiryResponse {
|
|||||||
UpdatedAt: i.UpdatedAt,
|
UpdatedAt: i.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateAndSanitize validates and sanitizes the form
|
||||||
|
func (f *CreateInquiryForm) ValidateAndSanitize(xssPolicy *security.XSSPolicy) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Sanitize and validate FullName
|
||||||
|
f.FullName, err = xssPolicy.ValidateAndSanitizeInput(f.FullName, "text")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize and validate CompanyName
|
||||||
|
f.CompanyName, err = xssPolicy.ValidateAndSanitizeInput(f.CompanyName, "text")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize and validate WorkEmail (email validation is separate)
|
||||||
|
f.WorkEmail, err = xssPolicy.ValidateAndSanitizeInput(f.WorkEmail, "email")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize and validate PhoneNumber
|
||||||
|
f.PhoneNumber, err = xssPolicy.ValidateAndSanitizeInput(f.PhoneNumber, "phone")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard govalidator validation
|
||||||
|
_, err = govalidator.ValidateStruct(f)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSanitize validates and sanitizes the status update form
|
||||||
|
func (f *UpdateInquiryStatusForm) ValidateAndSanitize(xssPolicy *security.XSSPolicy) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Sanitize Reason
|
||||||
|
f.Reason, err = xssPolicy.ValidateAndSanitizeInput(f.Reason, "text")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize Description
|
||||||
|
f.Description, err = xssPolicy.ValidateAndSanitizeInput(f.Description, "html")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard govalidator validation
|
||||||
|
_, err = govalidator.ValidateStruct(f)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSanitize validates and sanitizes the search form
|
||||||
|
func (f *SearchInquiriesForm) ValidateAndSanitize(xssPolicy *security.XSSPolicy) error {
|
||||||
|
if f.Search != nil {
|
||||||
|
sanitized, err := xssPolicy.ValidateAndSanitizeInput(*f.Search, "text")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*f.Search = sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard govalidator validation
|
||||||
|
_, err := govalidator.ValidateStruct(f)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"tm/pkg/hcaptcha"
|
"tm/pkg/hcaptcha"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
|
"tm/pkg/security"
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,14 +15,16 @@ type Handler struct {
|
|||||||
service Service
|
service Service
|
||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
hcaptchaVerifier hcaptcha.Verifier
|
hcaptchaVerifier hcaptcha.Verifier
|
||||||
|
xssPolicy *security.XSSPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInquiryHandler creates a new inquiry handler
|
// NewInquiryHandler creates a new inquiry handler
|
||||||
func NewHandler(service Service, logger logger.Logger, hcaptchaVerifier hcaptcha.Verifier) *Handler {
|
func NewHandler(service Service, logger logger.Logger, hcaptchaVerifier hcaptcha.Verifier, xssPolicy *security.XSSPolicy) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
service: service,
|
service: service,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
hcaptchaVerifier: hcaptchaVerifier,
|
hcaptchaVerifier: hcaptchaVerifier,
|
||||||
|
xssPolicy: xssPolicy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +47,8 @@ func (h *Handler) CreateInquiry(c echo.Context) error {
|
|||||||
return response.BadRequest(c, "Invalid request format", "")
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate form
|
// Validate and sanitize form
|
||||||
if _, err := govalidator.ValidateStruct(form); err != nil {
|
if err := form.ValidateAndSanitize(h.xssPolicy); err != nil {
|
||||||
return response.ValidationError(c, err.Error(), "")
|
return response.ValidationError(c, err.Error(), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +138,8 @@ func (h *Handler) UpdateInquiryStatus(c echo.Context) error {
|
|||||||
return response.BadRequest(c, "Invalid request format", "")
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate form
|
// Validate and sanitize form
|
||||||
if _, err := govalidator.ValidateStruct(form); err != nil {
|
if err := form.ValidateAndSanitize(h.xssPolicy); err != nil {
|
||||||
return response.ValidationError(c, err.Error(), "")
|
return response.ValidationError(c, err.Error(), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +179,8 @@ func (h *Handler) SearchInquiries(c echo.Context) error {
|
|||||||
return response.BadRequest(c, "Invalid request format", "")
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate form
|
// Validate and sanitize form
|
||||||
if _, err := govalidator.ValidateStruct(form); err != nil {
|
if err := form.ValidateAndSanitize(h.xssPolicy); err != nil {
|
||||||
return response.ValidationError(c, err.Error(), "")
|
return response.ValidationError(c, err.Error(), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/notification"
|
"tm/pkg/notification"
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
|
"tm/pkg/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service defines business logic for inquiry operations
|
// Service defines business logic for inquiry operations
|
||||||
@@ -23,14 +24,16 @@ type inquiryService struct {
|
|||||||
repository Repository
|
repository Repository
|
||||||
sdk notification.SDK
|
sdk notification.SDK
|
||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
|
xssPolicy *security.XSSPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new inquiry service
|
// NewService creates a new inquiry service
|
||||||
func NewService(repository Repository, sdk notification.SDK, logger logger.Logger) Service {
|
func NewService(repository Repository, sdk notification.SDK, logger logger.Logger, xssPolicy *security.XSSPolicy) Service {
|
||||||
return &inquiryService{
|
return &inquiryService{
|
||||||
repository: repository,
|
repository: repository,
|
||||||
sdk: sdk,
|
sdk: sdk,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
xssPolicy: xssPolicy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +117,7 @@ func (s *inquiryService) Create(ctx context.Context, form *CreateInquiryForm) (*
|
|||||||
CompanyLogo: "",
|
CompanyLogo: "",
|
||||||
CompanyURL: "https://opplens.com",
|
CompanyURL: "https://opplens.com",
|
||||||
SupportEmail: "info@opplens.com",
|
SupportEmail: "info@opplens.com",
|
||||||
}),
|
}, s.xssPolicy),
|
||||||
Type: "alert",
|
Type: "alert",
|
||||||
Priority: "important",
|
Priority: "important",
|
||||||
EventType: notification.EventTypeEmail,
|
EventType: notification.EventTypeEmail,
|
||||||
@@ -137,7 +140,7 @@ func (s *inquiryService) Create(ctx context.Context, form *CreateInquiryForm) (*
|
|||||||
CompanyLogo: "",
|
CompanyLogo: "",
|
||||||
CompanyURL: "https://opplens.com",
|
CompanyURL: "https://opplens.com",
|
||||||
SupportEmail: "info@opplens.com",
|
SupportEmail: "info@opplens.com",
|
||||||
}),
|
}, s.xssPolicy),
|
||||||
Type: "alert",
|
Type: "alert",
|
||||||
Priority: "important",
|
Priority: "important",
|
||||||
EventType: notification.EventTypeEmail,
|
EventType: notification.EventTypeEmail,
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CSPConfig holds CSP configuration
|
||||||
|
type CSPConfig struct {
|
||||||
|
DefaultSrc []string
|
||||||
|
ScriptSrc []string
|
||||||
|
StyleSrc []string
|
||||||
|
ImgSrc []string
|
||||||
|
ConnectSrc []string
|
||||||
|
FontSrc []string
|
||||||
|
ObjectSrc []string
|
||||||
|
MediaSrc []string
|
||||||
|
FrameSrc []string
|
||||||
|
ReportURI string
|
||||||
|
ReportOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultCSPConfig returns a secure default CSP configuration
|
||||||
|
func DefaultCSPConfig() *CSPConfig {
|
||||||
|
return &CSPConfig{
|
||||||
|
DefaultSrc: []string{"'self'"},
|
||||||
|
ScriptSrc: []string{"'self'"},
|
||||||
|
StyleSrc: []string{"'self'"},
|
||||||
|
ImgSrc: []string{"'self'", "data:", "https:", "http:"},
|
||||||
|
ConnectSrc: []string{"'self'", "https://api.opplens.com"},
|
||||||
|
FontSrc: []string{"'self'", "https://fonts.gstatic.com"},
|
||||||
|
ObjectSrc: []string{"'none'"},
|
||||||
|
MediaSrc: []string{"'self'"},
|
||||||
|
FrameSrc: []string{"'none'"},
|
||||||
|
ReportURI: "/api/v1/csp-report",
|
||||||
|
ReportOnly: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSPSource represents a CSP directive source
|
||||||
|
type CSPSource string
|
||||||
|
|
||||||
|
// CSPDirective represents a CSP directive
|
||||||
|
type CSPDirective struct {
|
||||||
|
Name string
|
||||||
|
Sources []CSPSource
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSPMiddleware creates Echo middleware for Content Security Policy
|
||||||
|
func CSPMiddleware(config *CSPConfig) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Build CSP header
|
||||||
|
directives := []string{}
|
||||||
|
|
||||||
|
if len(config.DefaultSrc) > 0 {
|
||||||
|
directives = append(directives, "default-src "+strings.Join(toStringSlice(config.DefaultSrc), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.ScriptSrc) > 0 {
|
||||||
|
directives = append(directives, "script-src "+strings.Join(toStringSlice(config.ScriptSrc), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.StyleSrc) > 0 {
|
||||||
|
directives = append(directives, "style-src "+strings.Join(toStringSlice(config.StyleSrc), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.ImgSrc) > 0 {
|
||||||
|
directives = append(directives, "img-src "+strings.Join(toStringSlice(config.ImgSrc), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.ConnectSrc) > 0 {
|
||||||
|
directives = append(directives, "connect-src "+strings.Join(toStringSlice(config.ConnectSrc), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.FontSrc) > 0 {
|
||||||
|
directives = append(directives, "font-src "+strings.Join(toStringSlice(config.FontSrc), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.ObjectSrc) > 0 {
|
||||||
|
directives = append(directives, "object-src "+strings.Join(toStringSlice(config.ObjectSrc), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.MediaSrc) > 0 {
|
||||||
|
directives = append(directives, "media-src "+strings.Join(toStringSlice(config.MediaSrc), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.FrameSrc) > 0 {
|
||||||
|
directives = append(directives, "frame-src "+strings.Join(toStringSlice(config.FrameSrc), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.ReportURI != "" {
|
||||||
|
directives = append(directives, "report-uri "+config.ReportURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
cspHeader := strings.Join(directives, "; ")
|
||||||
|
|
||||||
|
if config.ReportOnly {
|
||||||
|
c.Response().Header().Set("Content-Security-Policy-Report-Only", cspHeader)
|
||||||
|
} else {
|
||||||
|
c.Response().Header().Set("Content-Security-Policy", cspHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSPReportHandler handles CSP violation reports
|
||||||
|
func CSPReportHandler(c echo.Context) error {
|
||||||
|
var report struct {
|
||||||
|
CSPReport struct {
|
||||||
|
DocumentURI string `json:"document-uri"`
|
||||||
|
ViolatedDirective string `json:"violated-directive"`
|
||||||
|
EffectiveDirective string `json:"effective-directive"`
|
||||||
|
OriginalPolicy string `json:"original-policy"`
|
||||||
|
BlockedURI string `json:"blocked-uri"`
|
||||||
|
StatusCode int `json:"status-code"`
|
||||||
|
} `json:"csp-report"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Bind(&report); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid CSP report"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log CSP violation (implement proper logging here)
|
||||||
|
// logger.Warn("CSP Violation", map[string]interface{}{
|
||||||
|
// "document_uri": report.CSPReport.DocumentURI,
|
||||||
|
// "violated_directive": report.CSPReport.ViolatedDirective,
|
||||||
|
// "blocked_uri": report.CSPReport.BlockedURI,
|
||||||
|
// })
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "reported"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStringSlice(sources []string) []string {
|
||||||
|
result := make([]string, len(sources))
|
||||||
|
for i, src := range sources {
|
||||||
|
result[i] = string(src)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
)
|
||||||
|
|
||||||
|
// XSSPolicy defines the sanitization policy
|
||||||
|
type XSSPolicy struct {
|
||||||
|
policy *bluemonday.Policy
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewXSSPolicy creates a new XSS sanitization policy
|
||||||
|
func NewXSSPolicy() *XSSPolicy {
|
||||||
|
p := bluemonday.UGCPolicy()
|
||||||
|
|
||||||
|
// Allow basic formatting but strip scripts and dangerous attributes
|
||||||
|
p.AllowAttrs("href", "target", "rel").OnElements("a")
|
||||||
|
p.AllowAttrs("src", "alt", "width", "height").OnElements("img")
|
||||||
|
p.AllowElements("p", "br", "strong", "em", "u", "h1", "h2", "h3", "h4", "h5", "h6")
|
||||||
|
p.AllowElements("ul", "ol", "li")
|
||||||
|
|
||||||
|
return &XSSPolicy{policy: p}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeHTML sanitizes HTML input, allowing safe tags
|
||||||
|
func (x *XSSPolicy) SanitizeHTML(input string) string {
|
||||||
|
return x.policy.Sanitize(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeText completely escapes HTML entities for safe text display
|
||||||
|
func (x *XSSPolicy) SanitizeText(input string) string {
|
||||||
|
return html.EscapeString(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeEmail sanitizes input for use in email contexts
|
||||||
|
func (x *XSSPolicy) SanitizeEmail(input string) string {
|
||||||
|
// For emails, we want to allow basic formatting but be strict
|
||||||
|
return strings.TrimSpace(x.SanitizeText(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeURL sanitizes URL input
|
||||||
|
func (x *XSSPolicy) SanitizeURL(input string) string {
|
||||||
|
// Remove javascript: and data: schemes
|
||||||
|
dangerous := regexp.MustCompile(`(?i)^(javascript|data|vbscript):`)
|
||||||
|
if dangerous.MatchString(input) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizePhoneNumber sanitizes phone number input
|
||||||
|
func (x *XSSPolicy) SanitizePhoneNumber(input string) string {
|
||||||
|
// Remove any HTML tags and trim whitespace
|
||||||
|
clean := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(input, "")
|
||||||
|
return strings.TrimSpace(clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSanitizeInput validates and sanitizes user input
|
||||||
|
func (x *XSSPolicy) ValidateAndSanitizeInput(input string, fieldType string) (string, error) {
|
||||||
|
if strings.TrimSpace(input) == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fieldType {
|
||||||
|
case "text":
|
||||||
|
return x.SanitizeText(input), nil
|
||||||
|
case "html":
|
||||||
|
return x.SanitizeHTML(input), nil
|
||||||
|
case "email":
|
||||||
|
return x.SanitizeEmail(input), nil
|
||||||
|
case "url":
|
||||||
|
return x.SanitizeURL(input), nil
|
||||||
|
case "phone":
|
||||||
|
return x.SanitizePhoneNumber(input), nil
|
||||||
|
default:
|
||||||
|
return x.SanitizeText(input), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user