Merge branch 'develop' into TM-309

This commit is contained in:
Mazyar
2025-12-08 12:08:44 +03:30
12 changed files with 768 additions and 27 deletions
+7 -5
View File
@@ -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)
+19 -2
View File
@@ -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)
+3
View File
@@ -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
+6
View File
@@ -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=
+349
View File
@@ -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,
)
}
+50 -1
View File
@@ -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, &notification.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, &notification.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,
+17 -7
View File
@@ -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
+75 -1
View File
@@ -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
}
+10 -8
View File
@@ -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(), "")
} }
+6 -3
View File
@@ -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,
+144
View File
@@ -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
}
+82
View File
@@ -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
}
}