diff --git a/cmd/web/main.go b/cmd/web/main.go index 98216da..f9c238e 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -248,6 +248,8 @@ func main() { // Initialize HTTP server e := bootstrap.InitHTTPServer(conf, logger) + router.SetupCSPSecurity(e) + // Register routes router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, xssPolicy) router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, fileStoreHandler, xssPolicy) diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 9b15f05..c1e73fc 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -22,17 +22,20 @@ import ( "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, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, xssPolicy *security.XSSPolicy) { - adminV1 := e.Group("/admin/v1") - - // Add CSP middleware to admin routes (stricter policy) +// SetupCSPSecurity registers global CSP middleware and the CSP report endpoint once. +func SetupCSPSecurity(e *echo.Echo) { 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 + publicCSPConfig := security.DefaultCSPConfig() + + e.Use(security.CSPMiddlewareForPaths("/admin", adminCSPConfig, "/api/v1", publicCSPConfig)) e.POST("/api/v1/csp-report", security.CSPReportHandler) +} + +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, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, xssPolicy *security.XSSPolicy) { + adminV1 := e.Group("/admin/v1") // Admin Users Routes adminUsersGP := adminV1.Group("/users") @@ -221,13 +224,6 @@ 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, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) { 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.POST("/login", customerHandler.Login) diff --git a/internal/company/service.go b/internal/company/service.go index fb85f1d..7428de9 100644 --- a/internal/company/service.go +++ b/internal/company/service.go @@ -16,6 +16,7 @@ type Service interface { Update(ctx context.Context, id string, form *CompanyForm) (*CompanyResponse, error) GetByID(ctx context.Context, id string) (*CompanyResponse, error) GetByIDs(ctx context.Context, ids []string) ([]*CompanyResponse, error) + GetNamesByIDs(ctx context.Context, ids []string) (map[string]*CompanyResponse, error) Delete(ctx context.Context, id string) error // Company listing and search @@ -389,6 +390,30 @@ func (s *companyService) GetByIDs(ctx context.Context, ids []string) ([]*Company return companyResponses, nil } +// GetNamesByIDs returns company id and name keyed by company ID without loading categories. +func (s *companyService) GetNamesByIDs(ctx context.Context, ids []string) (map[string]*CompanyResponse, error) { + if len(ids) == 0 { + return map[string]*CompanyResponse{}, nil + } + + companies, err := s.repository.GetByIDs(ctx, ids) + if err != nil { + s.logger.Error("Failed to get companies by IDs for names", map[string]interface{}{ + "count": len(ids), + "error": err.Error(), + }) + return nil, errors.New("failed to get companies by IDs") + } + + out := make(map[string]*CompanyResponse, len(companies)) + for _, c := range companies { + id := c.ID.Hex() + out[id] = &CompanyResponse{ID: id, Name: c.Name} + } + + return out, nil +} + // UpdateCompanyStatus updates company status func (s *companyService) UpdateStatus(ctx context.Context, id string, form *StatusForm) error { // Get company to check if exists diff --git a/internal/document_scraper/middleware.go b/internal/document_scraper/middleware.go index 9fc4f2b..360676a 100644 --- a/internal/document_scraper/middleware.go +++ b/internal/document_scraper/middleware.go @@ -1,6 +1,7 @@ package document_scraper import ( + "crypto/subtle" "strings" "tm/pkg/response" @@ -25,7 +26,7 @@ func APIKeyMiddleware(apiKey string) echo.MiddlewareFunc { return response.Unauthorized(c, "API key required. Use X-API-Key header or Authorization: ApiKey ") } - if apiKeyHeader != apiKey { + if subtle.ConstantTimeCompare([]byte(apiKeyHeader), []byte(apiKey)) != 1 { return response.Unauthorized(c, "Invalid API key") } diff --git a/internal/feedback/service.go b/internal/feedback/service.go index 691734e..d17a6c5 100644 --- a/internal/feedback/service.go +++ b/internal/feedback/service.go @@ -137,10 +137,19 @@ func (s *feedbackService) Search(ctx context.Context, criteria SearchForm, pagin meta := pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset) + tenderByID, companyByID := s.loadListDetails(ctx, page.Items) + result := make([]*FeedbackResponse, len(page.Items)) for i, feedback := range page.Items { - tender, company := s.getDetails(ctx, feedback.TenderID, feedbackCompanyID(feedback)) - result[i] = feedback.ToResponse(tender, company) + var tenderResp *tenderResponse + if t, ok := tenderByID[feedback.TenderID]; ok { + tenderResp = t + } + var companyResp *companyResponse + if cid := feedbackCompanyID(feedback); cid != "" { + companyResp = companyByID[cid] + } + result[i] = feedback.ToResponse(tenderResp, companyResp) } return &FeedbackListResponse{ @@ -175,6 +184,77 @@ func feedbackCompanyID(f Feedback) string { return *f.CompanyID } +func uniqueNonEmptyStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, v := range values { + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func collectFeedbackListIDs(items []Feedback) (tenderIDs, companyIDs []string) { + tenderIDs = make([]string, 0, len(items)) + companyIDs = make([]string, 0, len(items)) + for _, item := range items { + tenderIDs = append(tenderIDs, item.TenderID) + companyIDs = append(companyIDs, feedbackCompanyID(item)) + } + return uniqueNonEmptyStrings(tenderIDs), uniqueNonEmptyStrings(companyIDs) +} + +// loadListDetails batch-loads tender and company data for feedback list responses. +func (s *feedbackService) loadListDetails(ctx context.Context, items []Feedback) ( + map[string]*tenderResponse, + map[string]*companyResponse, +) { + tenderByID := make(map[string]*tenderResponse) + companyByID := make(map[string]*companyResponse) + + if len(items) == 0 { + return tenderByID, companyByID + } + + tenderIDs, companyIDs := collectFeedbackListIDs(items) + + if len(tenderIDs) > 0 { + tenders, err := s.tenderService.GetByIDs(ctx, tenderIDs) + if err != nil { + s.logger.Error("Failed to batch load tenders for feedback list", map[string]interface{}{ + "count": len(tenderIDs), + "error": err.Error(), + }) + } else { + for id, t := range tenders { + tenderByID[id] = tenderToFeedbackResponse(t) + } + } + } + + if len(companyIDs) > 0 { + companies, err := s.companyService.GetNamesByIDs(ctx, companyIDs) + if err != nil { + s.logger.Error("Failed to batch load companies for feedback list", map[string]interface{}{ + "count": len(companyIDs), + "error": err.Error(), + }) + } else { + for id, c := range companies { + companyByID[id] = companyToFeedbackResponse(c) + } + } + } + + return tenderByID, companyByID +} + func tenderToFeedbackResponse(t *tender.TenderResponse) *tenderResponse { if t == nil { return nil diff --git a/internal/tender/repository.go b/internal/tender/repository.go index 0c6d73d..1bcad44 100644 --- a/internal/tender/repository.go +++ b/internal/tender/repository.go @@ -18,6 +18,7 @@ type TenderRepository interface { // Tender CRUD operations Create(ctx context.Context, tender *Tender) error GetByID(ctx context.Context, id string) (*Tender, error) + GetByIDs(ctx context.Context, ids []string) ([]Tender, error) GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error) GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error) GetByContractFolderID(ctx context.Context, contractFolderID string) (*Tender, error) @@ -214,6 +215,33 @@ func (r *tenderRepository) GetByID(ctx context.Context, id string) (*Tender, err return tender, nil } +// GetByIDs retrieves tenders by IDs in a single query. +func (r *tenderRepository) GetByIDs(ctx context.Context, ids []string) ([]Tender, error) { + if len(ids) == 0 { + return nil, nil + } + + objectIDs := make([]bson.ObjectID, 0, len(ids)) + for _, id := range ids { + objectID, err := bson.ObjectIDFromHex(id) + if err != nil { + return nil, err + } + objectIDs = append(objectIDs, objectID) + } + + result, err := r.ormRepo.FindAll(ctx, bson.M{"_id": bson.M{"$in": objectIDs}}, orm.NewPaginationBuilder().Build()) + if err != nil { + r.logger.Error("Failed to get tenders by IDs", map[string]interface{}{ + "count": len(ids), + "error": err.Error(), + }) + return nil, err + } + + return result.Items, nil +} + // GetByContractNoticeID retrieves a tender by contract notice ID func (r *tenderRepository) GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error) { filter := bson.M{"contract_notice_id": contractNoticeID} diff --git a/internal/tender/service.go b/internal/tender/service.go index eb2cc78..730f5cd 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -47,6 +47,7 @@ type TenderDocumentDownload struct { type Service interface { // Tender operations GetByID(ctx context.Context, id string, language string) (*TenderResponse, error) + GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error) Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error) Delete(ctx context.Context, id string) error Search(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*SearchResponse, error) @@ -82,7 +83,7 @@ type tenderService struct { } const ( - aiSummaryEnrichmentTimeout = 2 * time.Second + aiSummaryEnrichmentTimeout = 2 * time.Second aiTranslationEnrichmentTimeout = 2 * time.Second ) @@ -128,6 +129,30 @@ func (s *tenderService) GetByID(ctx context.Context, id string, language string) return s.buildDetailResponse(ctx, tender, language), nil } +// GetByIDs loads multiple tenders for list endpoints without MinIO or AI enrichment. +func (s *tenderService) GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error) { + if len(ids) == 0 { + return map[string]*TenderResponse{}, nil + } + + tenders, err := s.repository.GetByIDs(ctx, ids) + if err != nil { + s.logger.Error("Failed to retrieve tenders by IDs", map[string]interface{}{ + "count": len(ids), + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to retrieve tenders: %w", err) + } + + out := make(map[string]*TenderResponse, len(tenders)) + for i := range tenders { + resp := tenders[i].ToResponseWithLanguage("") + out[resp.ID] = resp + } + + return out, nil +} + // buildDetailResponse maps a tender to API form and enriches translation (MinIO) and summary. func (s *tenderService) buildDetailResponse(ctx context.Context, tender *Tender, language string) *TenderResponse { resp := tender.ToResponseWithLanguage("") @@ -1271,8 +1296,8 @@ func (s *tenderService) ListTendersWithScrapedDocuments(ctx context.Context, pag } s.logger.Info("Listing tenders with scraped documents from MinIO", map[string]interface{}{ - "limit": pagination.Limit, - "offset": pagination.Offset, + "limit": pagination.Limit, + "offset": pagination.Offset, "sync_mongo_metadata": syncMongoMetadata, }) diff --git a/pkg/security/csp.go b/pkg/security/csp.go index 7bf066c..e32fbdc 100644 --- a/pkg/security/csp.go +++ b/pkg/security/csp.go @@ -108,6 +108,27 @@ func CSPMiddleware(config *CSPConfig) echo.MiddlewareFunc { } } +// CSPMiddlewareForPaths applies CSP middleware based on request path prefix. +// Requests matching no prefix proceed without a CSP header. +func CSPMiddlewareForPaths(adminPrefix string, adminConfig *CSPConfig, publicPrefix string, publicConfig *CSPConfig) echo.MiddlewareFunc { + adminMW := CSPMiddleware(adminConfig) + publicMW := CSPMiddleware(publicConfig) + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + path := c.Request().URL.Path + switch { + case strings.HasPrefix(path, adminPrefix): + return adminMW(next)(c) + case strings.HasPrefix(path, publicPrefix): + return publicMW(next)(c) + default: + return next(c) + } + } + } +} + // CSPReportHandler handles CSP violation reports func CSPReportHandler(c echo.Context) error { var report struct {