Pulse/internal/api/reporting_handlers.go
rcourtman 668cdf3393 feat(license): add audit_logging, advanced_sso, advanced_reporting to Pro tier
Major changes:
- Add audit_logging, advanced_sso, advanced_reporting features to Pro tier
- Persist session username for RBAC authorization after restart
- Add hot-dev auto-detection for pulse-pro binary (enables SQLite audit logging)

Frontend improvements:
- Replace isEnterprise() with hasFeature() for granular feature gating
- Update AuditLogPanel, OIDCPanel, RolesPanel, UserAssignmentsPanel, AISettings
- Update AuditWebhookPanel to use hasFeature('audit_logging')

Backend changes:
- Session store now persists and restores username field
- Update CreateSession/CreateOIDCSession to accept username parameter
- GetSessionUsername falls back to persisted username after restart

Testing:
- Update license_test.go to reflect Pro tier feature changes
- Update session tests for new username parameter
2026-01-10 12:55:02 +00:00

121 lines
3.5 KiB
Go

package api
import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
)
// validResourceID matches safe resource identifiers for filenames
var validResourceID = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// ReportingHandlers handles reporting-related requests
type ReportingHandlers struct{}
// NewReportingHandlers creates a new ReportingHandlers
func NewReportingHandlers() *ReportingHandlers {
return &ReportingHandlers{}
}
// HandleGenerateReport generates a report
func (h *ReportingHandlers) HandleGenerateReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
engine := reporting.GetEngine()
if engine == nil {
writeErrorResponse(w, http.StatusInternalServerError, "engine_unavailable", "Reporting engine not initialized", nil)
return
}
q := r.URL.Query()
format := reporting.ReportFormat(q.Get("format"))
if format == "" {
format = reporting.FormatPDF
}
// Validate format is one of known values
if format != reporting.FormatPDF && format != reporting.FormatCSV {
writeErrorResponse(w, http.StatusBadRequest, "invalid_format", "Format must be 'pdf' or 'csv'", nil)
return
}
resourceType := q.Get("resourceType")
resourceID := q.Get("resourceId")
if resourceType == "" || resourceID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_params", "resourceType and resourceId are required", nil)
return
}
// Validate resourceType and resourceID format to prevent injection in filename
if !validResourceID.MatchString(resourceType) || len(resourceType) > 64 {
writeErrorResponse(w, http.StatusBadRequest, "invalid_resource_type", "Invalid resourceType format", nil)
return
}
if !validResourceID.MatchString(resourceID) || len(resourceID) > 128 {
writeErrorResponse(w, http.StatusBadRequest, "invalid_resource_id", "Invalid resourceId format", nil)
return
}
metricType := q.Get("metricType")
// Parse range
end := time.Now()
if q.Get("end") != "" {
if t, err := time.Parse(time.RFC3339, q.Get("end")); err == nil {
end = t
}
}
start := end.Add(-24 * time.Hour)
if q.Get("start") != "" {
if t, err := time.Parse(time.RFC3339, q.Get("start")); err == nil {
start = t
}
}
req := reporting.MetricReportRequest{
ResourceType: resourceType,
ResourceID: resourceID,
MetricType: metricType,
Start: start,
End: end,
Format: format,
Title: q.Get("title"),
}
data, contentType, err := engine.Generate(req)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "generation_failed", "Failed to generate report", nil)
return
}
w.Header().Set("Content-Type", contentType)
// Build safe filename - sanitize resourceID to prevent header injection
safeResourceID := sanitizeFilename(resourceID)
filename := fmt.Sprintf("report-%s-%s.%s", safeResourceID, time.Now().Format("20060102"), format)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Write(data)
}
// sanitizeFilename removes or replaces characters that could cause issues in filenames or headers
func sanitizeFilename(s string) string {
// Remove any characters that could break Content-Disposition header
s = strings.ReplaceAll(s, "\"", "")
s = strings.ReplaceAll(s, "\\", "")
s = strings.ReplaceAll(s, "/", "-")
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
// Limit length
if len(s) > 64 {
s = s[:64]
}
return s
}