mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-21 18:46:08 +00:00
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
121 lines
3.5 KiB
Go
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
|
|
}
|