mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 14:07:31 +00:00
eecf45fafd
The customers most likely to lapse are the ones who stopped opening Pulse, so the in-app "This week" card cannot reach them. Report schedules gain a kind: the default resources kind is the existing PDF or CSV performance report, and patrol_digest emails the same weekly "what Patrol did for you" rollup the Patrol page shows, in plain language, for the whole workspace. It reuses the existing scheduler, cadence, recipients, tenant email config, and Pro advanced-reporting entitlement rather than adding a second scheduler. Digest schedules are weekly and email-only; the server fixes scope, format, and attachments, and a run with no AI service, no Patrol, or no email destination records a last_error that names the missing piece instead of sending an empty week. Nothing is written to disk. The digest assembly moves from the HTTP handler into AISettingsHandler.BuildPatrolDigest so the schedule runner and the endpoint share one code path. docs/MSP.md documents the kind for providers; the Settings > Reporting "Report type" selector follows in a separate change once it has a Pro-licensed browser pass.
1032 lines
36 KiB
Go
1032 lines
36 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
reportScheduleAttachmentLimitBytes = 15 * 1024 * 1024
|
|
reportScheduleTickInterval = time.Minute
|
|
reportScheduleMissedRunGrace = 24 * time.Hour
|
|
)
|
|
|
|
type reportScheduleListResponse struct {
|
|
Schedules []config.ReportSchedule `json:"schedules"`
|
|
}
|
|
|
|
type reportScheduleRunResponse struct {
|
|
Schedule config.ReportSchedule `json:"schedule"`
|
|
Status string `json:"status"`
|
|
Path string `json:"path,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
}
|
|
|
|
func (h *ReportingHandlers) HandleListReportSchedules(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
store, err := h.loadReportScheduleStore(r.Context())
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
|
|
return
|
|
}
|
|
writeReportScheduleJSON(w, http.StatusOK, reportScheduleListResponse{Schedules: store.Schedules})
|
|
}
|
|
|
|
func (h *ReportingHandlers) HandleCreateReportSchedule(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var incoming config.ReportSchedule
|
|
if err := decodeReportScheduleBody(w, r, &incoming); err != nil {
|
|
return
|
|
}
|
|
|
|
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
incoming.ID = uuid.NewString()
|
|
incoming.CreatedAt = now
|
|
incoming.UpdatedAt = now
|
|
schedule, err := h.prepareReportSchedule(r.Context(), incoming, now, false)
|
|
if err != nil {
|
|
writeReportScheduleValidationError(w, err)
|
|
return
|
|
}
|
|
store.Schedules = append(store.Schedules, schedule)
|
|
if err := persistence.SaveReportScheduleStore(*store); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil)
|
|
return
|
|
}
|
|
writeReportScheduleJSON(w, http.StatusCreated, schedule)
|
|
}
|
|
|
|
func (h *ReportingHandlers) HandleUpdateReportSchedule(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
scheduleID := strings.TrimSpace(r.PathValue("id"))
|
|
if scheduleID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil)
|
|
return
|
|
}
|
|
var incoming config.ReportSchedule
|
|
if err := decodeReportScheduleBody(w, r, &incoming); err != nil {
|
|
return
|
|
}
|
|
|
|
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
|
|
return
|
|
}
|
|
index := findReportScheduleIndex(store.Schedules, scheduleID)
|
|
if index < 0 {
|
|
writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil)
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
existing := store.Schedules[index]
|
|
incoming.ID = existing.ID
|
|
incoming.CreatedAt = existing.CreatedAt
|
|
incoming.UpdatedAt = now
|
|
incoming.LastRunAt = existing.LastRunAt
|
|
incoming.LastRunStatus = existing.LastRunStatus
|
|
incoming.LastError = existing.LastError
|
|
incoming.LastOccurrenceKey = existing.LastOccurrenceKey
|
|
schedule, err := h.prepareReportSchedule(r.Context(), incoming, now, false)
|
|
if err != nil {
|
|
writeReportScheduleValidationError(w, err)
|
|
return
|
|
}
|
|
store.Schedules[index] = schedule
|
|
if err := persistence.SaveReportScheduleStore(*store); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil)
|
|
return
|
|
}
|
|
writeReportScheduleJSON(w, http.StatusOK, schedule)
|
|
}
|
|
|
|
func (h *ReportingHandlers) HandleDeleteReportSchedule(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
scheduleID := strings.TrimSpace(r.PathValue("id"))
|
|
if scheduleID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil)
|
|
return
|
|
}
|
|
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
|
|
return
|
|
}
|
|
index := findReportScheduleIndex(store.Schedules, scheduleID)
|
|
if index < 0 {
|
|
writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil)
|
|
return
|
|
}
|
|
store.Schedules = append(store.Schedules[:index], store.Schedules[index+1:]...)
|
|
if err := persistence.SaveReportScheduleStore(*store); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *ReportingHandlers) HandleRunReportSchedule(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
scheduleID := strings.TrimSpace(r.PathValue("id"))
|
|
if scheduleID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil)
|
|
return
|
|
}
|
|
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
|
|
return
|
|
}
|
|
index := findReportScheduleIndex(store.Schedules, scheduleID)
|
|
if index < 0 {
|
|
writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil)
|
|
return
|
|
}
|
|
|
|
result, updated := h.runReportSchedule(r.Context(), persistence, store.Schedules[index], time.Now().UTC(), true, "")
|
|
store.Schedules[index] = updated
|
|
if err := persistence.SaveReportScheduleStore(*store); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule status", nil)
|
|
return
|
|
}
|
|
status := http.StatusOK
|
|
if updated.LastRunStatus == config.ReportScheduleLastRunFailed {
|
|
status = http.StatusInternalServerError
|
|
}
|
|
writeReportScheduleJSON(w, status, reportScheduleRunResponse{
|
|
Schedule: updated,
|
|
Status: updated.LastRunStatus,
|
|
Path: result.path,
|
|
Email: result.email,
|
|
})
|
|
}
|
|
|
|
func decodeReportScheduleBody(w http.ResponseWriter, r *http.Request, target *config.ReportSchedule) error {
|
|
r.Body = http.MaxBytesReader(w, r.Body, reportingMultiReportBodyMax)
|
|
decoder := json.NewDecoder(r.Body)
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid report schedule body", nil)
|
|
return err
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != nil && !errors.Is(err, io.EOF) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid report schedule body", nil)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeReportScheduleJSON(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
type reportScheduleValidationError struct {
|
|
code string
|
|
message string
|
|
}
|
|
|
|
func (e reportScheduleValidationError) Error() string {
|
|
return e.message
|
|
}
|
|
|
|
func writeReportScheduleValidationError(w http.ResponseWriter, err error) {
|
|
var validationErr reportScheduleValidationError
|
|
if errors.As(err, &validationErr) {
|
|
writeErrorResponse(w, http.StatusBadRequest, validationErr.code, validationErr.message, nil)
|
|
return
|
|
}
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_schedule", err.Error(), nil)
|
|
}
|
|
|
|
func (h *ReportingHandlers) reportSchedulePersistenceAndStore(ctx context.Context) (*config.ConfigPersistence, *config.ReportScheduleStore, error) {
|
|
persistence, err := h.reportSchedulePersistence(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
store, err := persistence.LoadReportScheduleStore()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if store == nil {
|
|
empty := config.EmptyReportScheduleStore()
|
|
store = &empty
|
|
}
|
|
return persistence, store, nil
|
|
}
|
|
|
|
func (h *ReportingHandlers) loadReportScheduleStore(ctx context.Context) (*config.ReportScheduleStore, error) {
|
|
_, store, err := h.reportSchedulePersistenceAndStore(ctx)
|
|
return store, err
|
|
}
|
|
|
|
func (h *ReportingHandlers) reportSchedulePersistence(ctx context.Context) (*config.ConfigPersistence, error) {
|
|
if h == nil || h.mtMonitor == nil {
|
|
return nil, fmt.Errorf("multi-tenant monitor is not configured")
|
|
}
|
|
orgID := GetOrgID(ctx)
|
|
if strings.TrimSpace(orgID) == "" {
|
|
orgID = "default"
|
|
}
|
|
monitor, err := h.mtMonitor.GetMonitor(orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
persistence := monitor.GetConfigPersistence()
|
|
if persistence == nil {
|
|
return nil, fmt.Errorf("config persistence is not configured")
|
|
}
|
|
return persistence, nil
|
|
}
|
|
|
|
func findReportScheduleIndex(schedules []config.ReportSchedule, id string) int {
|
|
for i := range schedules {
|
|
if schedules[i].ID == id {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule config.ReportSchedule, now time.Time, statusOnly bool) (config.ReportSchedule, error) {
|
|
schedule = config.NormalizeReportSchedule(schedule)
|
|
if schedule.ID == "" {
|
|
return schedule, reportScheduleValidationError{code: "invalid_schedule_id", message: "Schedule ID is required"}
|
|
}
|
|
if statusOnly {
|
|
return schedule, nil
|
|
}
|
|
if schedule.Name == "" || len(schedule.Name) > 80 {
|
|
return schedule, reportScheduleValidationError{code: "invalid_name", message: "Schedule name is required and must be 80 characters or fewer"}
|
|
}
|
|
switch schedule.Kind {
|
|
case config.ReportScheduleKindResources:
|
|
case config.ReportScheduleKindPatrolDigest:
|
|
return preparePatrolDigestSchedule(schedule, now)
|
|
default:
|
|
return schedule, reportScheduleValidationError{code: "invalid_kind", message: "Schedule kind must be resources or patrol_digest"}
|
|
}
|
|
if schedule.Format == "" {
|
|
schedule.Format = config.ReportScheduleFormatPDF
|
|
}
|
|
if schedule.Format != config.ReportScheduleFormatPDF && schedule.Format != config.ReportScheduleFormatCSV {
|
|
return schedule, reportScheduleValidationError{code: "invalid_format", message: "Schedule format must be pdf or csv"}
|
|
}
|
|
if schedule.Delivery.Method == "" {
|
|
schedule.Delivery.Method = config.ReportScheduleDeliveryEmail
|
|
}
|
|
if schedule.Delivery.Method != config.ReportScheduleDeliveryEmail && schedule.Delivery.Method != config.ReportScheduleDeliveryDisk {
|
|
return schedule, reportScheduleValidationError{code: "invalid_delivery", message: "Delivery method must be email or disk"}
|
|
}
|
|
if !schedule.Delivery.Attach && !schedule.Delivery.SaveToDisk {
|
|
schedule.Delivery.Attach = true
|
|
schedule.Delivery.SaveToDisk = true
|
|
}
|
|
if err := validateReportScheduleCadence(schedule.Cadence); err != nil {
|
|
return schedule, err
|
|
}
|
|
if err := h.validateReportScheduleScope(ctx, schedule.Scope); err != nil {
|
|
return schedule, err
|
|
}
|
|
next, err := nextReportScheduleRunAt(schedule, now)
|
|
if err != nil {
|
|
return schedule, err
|
|
}
|
|
schedule.NextRunAt = &next
|
|
return schedule, nil
|
|
}
|
|
|
|
func validateReportScheduleCadence(cadence config.ReportScheduleCadence) error {
|
|
if cadence.Type != config.ReportScheduleCadenceMonthly && cadence.Type != config.ReportScheduleCadenceWeekly {
|
|
return reportScheduleValidationError{code: "invalid_cadence", message: "Cadence must be monthly or weekly"}
|
|
}
|
|
if cadence.Type == config.ReportScheduleCadenceMonthly && (cadence.DayOfMonth < 1 || cadence.DayOfMonth > 28) {
|
|
return reportScheduleValidationError{code: "invalid_day_of_month", message: "Monthly schedules must use a day from 1 to 28"}
|
|
}
|
|
if cadence.Type == config.ReportScheduleCadenceWeekly {
|
|
if _, ok := parseReportScheduleWeekday(cadence.Weekday); !ok {
|
|
return reportScheduleValidationError{code: "invalid_weekday", message: "Weekly schedules must include a weekday"}
|
|
}
|
|
}
|
|
if _, err := time.Parse("15:04", cadence.Time); err != nil {
|
|
return reportScheduleValidationError{code: "invalid_time", message: "Schedule time must use HH:MM"}
|
|
}
|
|
if _, err := reportScheduleLocation(cadence.Timezone); err != nil {
|
|
return reportScheduleValidationError{code: "invalid_timezone", message: "Schedule timezone must be a valid IANA timezone"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *ReportingHandlers) validateReportScheduleScope(ctx context.Context, scope config.ReportScheduleScope) error {
|
|
if len(scope.Resources) == 0 && len(scope.Tags) == 0 {
|
|
return reportScheduleValidationError{code: "invalid_scope", message: "Select at least one resource or tag"}
|
|
}
|
|
definition := performanceReportDefinition()
|
|
if len(scope.Resources) > definition.MultiResourceMax {
|
|
return reportScheduleValidationError{code: "too_many_resources", message: fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax)}
|
|
}
|
|
for _, res := range scope.Resources {
|
|
if !validResourceID.MatchString(res.ResourceID) || len(res.ResourceID) > 128 {
|
|
return reportScheduleValidationError{code: "invalid_resource_id", message: "Resource IDs must match [a-zA-Z0-9._:-]+ and be 128 characters or fewer"}
|
|
}
|
|
if _, err := normalizeReportResourceType(res.ResourceType); err != nil {
|
|
return reportScheduleValidationError{code: "invalid_resource_type", message: err.Error()}
|
|
}
|
|
}
|
|
for _, tag := range scope.Tags {
|
|
if len(tag) > 64 || strings.ContainsAny(tag, "\r\n\t") {
|
|
return reportScheduleValidationError{code: "invalid_tag", message: "Tags must be 64 characters or fewer and cannot contain control characters"}
|
|
}
|
|
}
|
|
if len(scope.Tags) > 0 {
|
|
if _, err := h.resolveReportScheduleResources(ctx, GetOrgID(ctx), scope); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *ReportingHandlers) resolveReportScheduleResources(ctx context.Context, orgID string, scope config.ReportScheduleScope) ([]multiReportResourceEntry, error) {
|
|
definition := performanceReportDefinition()
|
|
entries := make([]multiReportResourceEntry, 0, len(scope.Resources))
|
|
seen := map[string]struct{}{}
|
|
appendEntry := func(resourceType, resourceID string) error {
|
|
resourceType, err := normalizeReportResourceType(resourceType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := resourceType + "\x00" + resourceID
|
|
if _, exists := seen[key]; exists {
|
|
return nil
|
|
}
|
|
seen[key] = struct{}{}
|
|
entries = append(entries, multiReportResourceEntry{ResourceType: resourceType, ResourceID: resourceID})
|
|
return nil
|
|
}
|
|
for _, res := range scope.Resources {
|
|
if err := appendEntry(res.ResourceType, res.ResourceID); err != nil {
|
|
return nil, reportScheduleValidationError{code: "invalid_resource_type", message: err.Error()}
|
|
}
|
|
}
|
|
|
|
if len(scope.Tags) > 0 {
|
|
snapshot, ok := h.getReportingEnrichmentSnapshot(ctx, orgID)
|
|
if !ok {
|
|
return nil, reportScheduleValidationError{code: "scope_unavailable", message: "Tagged schedule scope cannot be resolved until resource inventory is available"}
|
|
}
|
|
tags := map[string]struct{}{}
|
|
for _, tag := range scope.Tags {
|
|
tags[strings.ToLower(strings.TrimSpace(tag))] = struct{}{}
|
|
}
|
|
for _, resource := range snapshot.Resources {
|
|
if !resourceHasAnyReportScheduleTag(resource, tags) {
|
|
continue
|
|
}
|
|
resourceType := reporting.CanonicalResourceType(string(resource.Type))
|
|
if resourceType == "" || strings.TrimSpace(resource.ID) == "" {
|
|
continue
|
|
}
|
|
if err := appendEntry(resourceType, resource.ID); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
if len(entries) == 0 {
|
|
return nil, reportScheduleValidationError{code: "empty_scope", message: "Schedule scope did not match any reportable resources"}
|
|
}
|
|
if len(entries) > definition.MultiResourceMax {
|
|
return nil, reportScheduleValidationError{code: "too_many_resources", message: fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax)}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func resourceHasAnyReportScheduleTag(resource unifiedresources.Resource, tags map[string]struct{}) bool {
|
|
for _, tag := range resource.Tags {
|
|
if _, ok := tags[strings.ToLower(strings.TrimSpace(tag))]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type reportScheduleRunResult struct {
|
|
path string
|
|
email string
|
|
}
|
|
|
|
func (h *ReportingHandlers) runReportSchedule(ctx context.Context, persistence *config.ConfigPersistence, schedule config.ReportSchedule, now time.Time, manual bool, occurrenceKey string) (reportScheduleRunResult, config.ReportSchedule) {
|
|
h.scheduleRunMu.Lock()
|
|
defer h.scheduleRunMu.Unlock()
|
|
|
|
schedule = config.NormalizeReportSchedule(schedule)
|
|
result := reportScheduleRunResult{}
|
|
schedule.LastRunAt = &now
|
|
schedule.UpdatedAt = now
|
|
if occurrenceKey != "" {
|
|
schedule.LastOccurrenceKey = occurrenceKey
|
|
}
|
|
if next, err := nextReportScheduleRunAt(schedule, now); err == nil {
|
|
schedule.NextRunAt = &next
|
|
}
|
|
if err := h.requireCommercialFeature(ctx, featureAdvancedReportingValue); err != nil {
|
|
return result, markReportScheduleFailed(schedule, now, fmt.Errorf("advanced reporting entitlement required: %w", err))
|
|
}
|
|
if schedule.Kind == config.ReportScheduleKindPatrolDigest {
|
|
return h.runPatrolDigestSchedule(ctx, persistence, schedule, now)
|
|
}
|
|
|
|
orgID := GetOrgID(ctx)
|
|
resources, err := h.resolveReportScheduleResources(ctx, orgID, schedule.Scope)
|
|
if err != nil {
|
|
return result, markReportScheduleFailed(schedule, now, err)
|
|
}
|
|
start, end, err := reportScheduleWindow(schedule, now, manual)
|
|
if err != nil {
|
|
return result, markReportScheduleFailed(schedule, now, err)
|
|
}
|
|
|
|
report, err := h.generateMultiReportFromBody(ctx, multiReportRequestBody{
|
|
Resources: resources,
|
|
Format: schedule.Format,
|
|
Start: start.UTC().Format(time.RFC3339),
|
|
End: end.UTC().Format(time.RFC3339),
|
|
Title: schedule.Name,
|
|
}, now)
|
|
if err != nil {
|
|
return result, markReportScheduleFailed(schedule, now, err)
|
|
}
|
|
|
|
path, err := saveGeneratedReport(persistence, schedule, report)
|
|
if err != nil {
|
|
return result, markReportScheduleFailed(schedule, now, err)
|
|
}
|
|
result.path = path
|
|
|
|
if schedule.Delivery.Method == config.ReportScheduleDeliveryEmail {
|
|
emailStatus, err := sendScheduledReportEmail(persistence, schedule, report, path)
|
|
if err != nil {
|
|
return result, markReportScheduleFailed(schedule, now, err)
|
|
}
|
|
result.email = emailStatus
|
|
}
|
|
|
|
schedule.LastRunStatus = config.ReportScheduleLastRunOK
|
|
schedule.LastError = ""
|
|
return result, schedule
|
|
}
|
|
|
|
func markReportScheduleFailed(schedule config.ReportSchedule, now time.Time, err error) config.ReportSchedule {
|
|
schedule.LastRunStatus = config.ReportScheduleLastRunFailed
|
|
schedule.LastError = strings.TrimSpace(err.Error())
|
|
schedule.LastRunAt = &now
|
|
schedule.UpdatedAt = now
|
|
return schedule
|
|
}
|
|
|
|
func reportScheduleWindow(schedule config.ReportSchedule, now time.Time, manual bool) (time.Time, time.Time, error) {
|
|
location, err := reportScheduleLocation(schedule.Cadence.Timezone)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, err
|
|
}
|
|
end := now.In(location)
|
|
if !manual {
|
|
occurrence, _, err := lastReportScheduleOccurrenceAt(schedule, now)
|
|
if err == nil {
|
|
end = occurrence.In(location)
|
|
}
|
|
}
|
|
switch schedule.Cadence.Type {
|
|
case config.ReportScheduleCadenceMonthly:
|
|
firstThisMonth := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, location)
|
|
firstPreviousMonth := firstThisMonth.AddDate(0, -1, 0)
|
|
return firstPreviousMonth.UTC(), firstThisMonth.UTC(), nil
|
|
case config.ReportScheduleCadenceWeekly:
|
|
return end.AddDate(0, 0, -7).UTC(), end.UTC(), nil
|
|
default:
|
|
return time.Time{}, time.Time{}, fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type)
|
|
}
|
|
}
|
|
|
|
func saveGeneratedReport(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport) (string, error) {
|
|
baseDir, err := securityutil.JoinStorageLeaf(persistence.DataDir(), "reports")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
generatedDir, err := securityutil.JoinStorageLeaf(baseDir, "generated")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
scheduleDir, err := securityutil.JoinStorageLeaf(generatedDir, securityutil.HashedStorageName(schedule.ID))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.MkdirAll(scheduleDir, 0o700); err != nil {
|
|
return "", fmt.Errorf("create report output directory: %w", err)
|
|
}
|
|
filename := sanitizeFilename(report.Filename)
|
|
if filename == "" {
|
|
filename = "report." + string(report.Format)
|
|
}
|
|
path, err := securityutil.JoinStorageLeaf(scheduleDir, filename)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.WriteFile(path, report.Data, 0o600); err != nil {
|
|
return "", fmt.Errorf("write generated report: %w", err)
|
|
}
|
|
pruneGeneratedReports(scheduleDir, schedule.RetentionCount)
|
|
return path, nil
|
|
}
|
|
|
|
func pruneGeneratedReports(dir string, retentionCount int) {
|
|
if retentionCount <= 0 {
|
|
retentionCount = config.DefaultReportScheduleRetentionCount
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
type generatedFile struct {
|
|
path string
|
|
modTime time.Time
|
|
}
|
|
files := make([]generatedFile, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
path, err := securityutil.JoinStorageLeaf(dir, entry.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, generatedFile{path: path, modTime: info.ModTime()})
|
|
}
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return files[i].modTime.After(files[j].modTime)
|
|
})
|
|
for i := retentionCount; i < len(files); i++ {
|
|
_ = os.Remove(files[i].path)
|
|
}
|
|
}
|
|
|
|
// scheduledReportEmailManager builds the email manager for a schedule from the
|
|
// tenant email config. It returns nil, nil when email is not configured or no
|
|
// recipient can be resolved, so callers decide how to report that.
|
|
func scheduledReportEmailManager(persistence *config.ConfigPersistence, schedule config.ReportSchedule) (*notifications.EnhancedEmailManager, error) {
|
|
emailCfg, err := persistence.LoadEmailConfig()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load email config: %w", err)
|
|
}
|
|
if emailCfg == nil || !emailCfg.Enabled {
|
|
return nil, nil
|
|
}
|
|
recipients := schedule.Delivery.To
|
|
if len(recipients) == 0 {
|
|
recipients = emailCfg.To
|
|
}
|
|
if len(recipients) == 0 && strings.TrimSpace(emailCfg.From) != "" {
|
|
recipients = []string{emailCfg.From}
|
|
}
|
|
if len(recipients) == 0 {
|
|
return nil, nil
|
|
}
|
|
providerConfig := notifications.EmailProviderConfig{
|
|
EmailConfig: notifications.EmailConfig{
|
|
Provider: emailCfg.Provider,
|
|
From: emailCfg.From,
|
|
To: recipients,
|
|
SMTPHost: emailCfg.SMTPHost,
|
|
SMTPPort: emailCfg.SMTPPort,
|
|
Username: emailCfg.Username,
|
|
Password: emailCfg.Password,
|
|
TLS: emailCfg.TLS,
|
|
StartTLS: emailCfg.StartTLS,
|
|
RateLimit: emailCfg.RateLimit,
|
|
},
|
|
Provider: emailCfg.Provider,
|
|
MaxRetries: 2,
|
|
RetryDelay: 3,
|
|
RateLimit: emailCfg.RateLimit,
|
|
StartTLS: emailCfg.StartTLS,
|
|
AuthRequired: emailCfg.Username != "" && emailCfg.Password != "",
|
|
}
|
|
return notifications.NewEnhancedEmailManager(providerConfig), nil
|
|
}
|
|
|
|
func sendScheduledReportEmail(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport, path string) (string, error) {
|
|
manager, err := scheduledReportEmailManager(persistence, schedule)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if manager == nil {
|
|
return "saved_to_disk_email_unconfigured", nil
|
|
}
|
|
subject := "Pulse report: " + schedule.Name
|
|
htmlBody := "<p>Your scheduled Pulse report is ready.</p>"
|
|
textBody := "Your scheduled Pulse report is ready."
|
|
attachments := []notifications.EmailAttachment{}
|
|
if schedule.Delivery.Attach && len(report.Data) <= reportScheduleAttachmentLimitBytes {
|
|
attachments = append(attachments, notifications.EmailAttachment{
|
|
Filename: report.Filename,
|
|
ContentType: report.ContentType,
|
|
Data: report.Data,
|
|
})
|
|
} else if schedule.Delivery.Attach && len(report.Data) > reportScheduleAttachmentLimitBytes {
|
|
htmlBody = "<p>Your scheduled Pulse report was generated but is too large to attach.</p><p>Saved path: " + htmlEscape(path) + "</p>"
|
|
textBody = "Your scheduled Pulse report was generated but is too large to attach.\nSaved path: " + path
|
|
} else {
|
|
htmlBody = "<p>Your scheduled Pulse report was generated and saved to disk.</p><p>Saved path: " + htmlEscape(path) + "</p>"
|
|
textBody = "Your scheduled Pulse report was generated and saved to disk.\nSaved path: " + path
|
|
}
|
|
if err := manager.SendEmailWithAttachments(subject, htmlBody, textBody, attachments); err != nil {
|
|
return "", err
|
|
}
|
|
if len(attachments) > 0 {
|
|
return "sent_with_attachment", nil
|
|
}
|
|
return "sent_saved_path", nil
|
|
}
|
|
|
|
func htmlEscape(value string) string {
|
|
replacer := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
|
|
return replacer.Replace(value)
|
|
}
|
|
|
|
func reportScheduleLocation(name string) (*time.Location, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || name == "Local" {
|
|
name = "UTC"
|
|
}
|
|
return time.LoadLocation(name)
|
|
}
|
|
|
|
func parseReportScheduleClock(clock string) (hour, minute int, err error) {
|
|
parsed, err := time.Parse("15:04", strings.TrimSpace(clock))
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
return parsed.Hour(), parsed.Minute(), nil
|
|
}
|
|
|
|
func parseReportScheduleWeekday(value string) (time.Weekday, bool) {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "sunday", "sun":
|
|
return time.Sunday, true
|
|
case "monday", "mon":
|
|
return time.Monday, true
|
|
case "tuesday", "tue":
|
|
return time.Tuesday, true
|
|
case "wednesday", "wed":
|
|
return time.Wednesday, true
|
|
case "thursday", "thu":
|
|
return time.Thursday, true
|
|
case "friday", "fri":
|
|
return time.Friday, true
|
|
case "saturday", "sat":
|
|
return time.Saturday, true
|
|
default:
|
|
return time.Sunday, false
|
|
}
|
|
}
|
|
|
|
func nextReportScheduleRunAt(schedule config.ReportSchedule, after time.Time) (time.Time, error) {
|
|
location, err := reportScheduleLocation(schedule.Cadence.Timezone)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
hour, minute, err := parseReportScheduleClock(schedule.Cadence.Time)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
localAfter := after.In(location)
|
|
switch schedule.Cadence.Type {
|
|
case config.ReportScheduleCadenceMonthly:
|
|
candidate := time.Date(localAfter.Year(), localAfter.Month(), schedule.Cadence.DayOfMonth, hour, minute, 0, 0, location)
|
|
if !candidate.After(localAfter) {
|
|
candidate = candidate.AddDate(0, 1, 0)
|
|
}
|
|
return candidate.UTC(), nil
|
|
case config.ReportScheduleCadenceWeekly:
|
|
weekday, ok := parseReportScheduleWeekday(schedule.Cadence.Weekday)
|
|
if !ok {
|
|
return time.Time{}, fmt.Errorf("invalid weekday")
|
|
}
|
|
dayDelta := (int(weekday) - int(localAfter.Weekday()) + 7) % 7
|
|
candidateDate := localAfter.AddDate(0, 0, dayDelta)
|
|
candidate := time.Date(candidateDate.Year(), candidateDate.Month(), candidateDate.Day(), hour, minute, 0, 0, location)
|
|
if !candidate.After(localAfter) {
|
|
candidate = candidate.AddDate(0, 0, 7)
|
|
}
|
|
return candidate.UTC(), nil
|
|
default:
|
|
return time.Time{}, fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type)
|
|
}
|
|
}
|
|
|
|
func lastReportScheduleOccurrenceAt(schedule config.ReportSchedule, now time.Time) (time.Time, string, error) {
|
|
location, err := reportScheduleLocation(schedule.Cadence.Timezone)
|
|
if err != nil {
|
|
return time.Time{}, "", err
|
|
}
|
|
hour, minute, err := parseReportScheduleClock(schedule.Cadence.Time)
|
|
if err != nil {
|
|
return time.Time{}, "", err
|
|
}
|
|
localNow := now.In(location)
|
|
var occurrence time.Time
|
|
switch schedule.Cadence.Type {
|
|
case config.ReportScheduleCadenceMonthly:
|
|
occurrence = time.Date(localNow.Year(), localNow.Month(), schedule.Cadence.DayOfMonth, hour, minute, 0, 0, location)
|
|
if occurrence.After(localNow) {
|
|
occurrence = occurrence.AddDate(0, -1, 0)
|
|
}
|
|
case config.ReportScheduleCadenceWeekly:
|
|
weekday, ok := parseReportScheduleWeekday(schedule.Cadence.Weekday)
|
|
if !ok {
|
|
return time.Time{}, "", fmt.Errorf("invalid weekday")
|
|
}
|
|
dayDelta := (int(localNow.Weekday()) - int(weekday) + 7) % 7
|
|
occurrenceDate := localNow.AddDate(0, 0, -dayDelta)
|
|
occurrence = time.Date(occurrenceDate.Year(), occurrenceDate.Month(), occurrenceDate.Day(), hour, minute, 0, 0, location)
|
|
if occurrence.After(localNow) {
|
|
occurrence = occurrence.AddDate(0, 0, -7)
|
|
}
|
|
default:
|
|
return time.Time{}, "", fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type)
|
|
}
|
|
return occurrence.UTC(), occurrenceKey(schedule, occurrence), nil
|
|
}
|
|
|
|
func occurrenceKey(schedule config.ReportSchedule, occurrence time.Time) string {
|
|
timezone := strings.TrimSpace(schedule.Cadence.Timezone)
|
|
if timezone == "" {
|
|
timezone = "UTC"
|
|
}
|
|
return schedule.Cadence.Type + ":" + occurrence.UTC().Format("2006-01-02T15:04:05Z07:00") + ":" + timezone
|
|
}
|
|
|
|
func (h *ReportingHandlers) RunReportScheduleScheduler(ctx context.Context) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.runDueReportSchedules(ctx, time.Now().UTC())
|
|
ticker := time.NewTicker(reportScheduleTickInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case now := <-ticker.C:
|
|
h.runDueReportSchedules(ctx, now.UTC())
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *ReportingHandlers) runDueReportSchedules(ctx context.Context, now time.Time) {
|
|
if h == nil || h.mtMonitor == nil {
|
|
return
|
|
}
|
|
orgIDs, err := h.mtMonitor.ListOrganizationIDs()
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("report schedules: list organizations")
|
|
return
|
|
}
|
|
for _, orgID := range orgIDs {
|
|
orgCtx := context.WithValue(ctx, OrgIDContextKey, orgID)
|
|
h.syncCommercialHistoryRetention(orgCtx, orgID, now)
|
|
h.runDueReportSchedulesForOrg(orgCtx, orgID, now)
|
|
}
|
|
}
|
|
|
|
func (h *ReportingHandlers) syncCommercialHistoryRetention(ctx context.Context, orgID string, now time.Time) {
|
|
if h == nil || h.mtMonitor == nil || h.commercialLicenseResolver == nil {
|
|
return
|
|
}
|
|
monitor, err := h.mtMonitor.GetMonitor(orgID)
|
|
if err != nil || monitor == nil || monitor.GetMetricsStore() == nil {
|
|
return
|
|
}
|
|
service := h.commercialLicenseResolver(ctx)
|
|
if service == nil {
|
|
monitor.GetMetricsStore().SetCommercialHistoryRetention(0, time.Time{})
|
|
return
|
|
}
|
|
activation := service.GetActivationState()
|
|
if activation == nil || activation.Continuity.DowngradeRetention == nil {
|
|
monitor.GetMetricsStore().SetCommercialHistoryRetention(0, time.Time{})
|
|
return
|
|
}
|
|
downgrade := normalizeDowngradeRetentionStateFromLicensing(*activation.Continuity.DowngradeRetention)
|
|
monitor.GetMetricsStore().SetCommercialHistoryRetention(
|
|
downgrade.CurrentHistoryDays,
|
|
time.Unix(downgrade.PurgeEligibleAt, 0).UTC(),
|
|
)
|
|
if now.UTC().Unix() < downgrade.PurgeEligibleAt || service.RequireFeature(featureAdvancedReportingValue) == nil {
|
|
return
|
|
}
|
|
persistence, err := h.reportSchedulePersistence(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := purgeGeneratedReportsAfterDowngrade(persistence); err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: purge downgrade artifacts")
|
|
}
|
|
}
|
|
|
|
func purgeGeneratedReportsAfterDowngrade(persistence *config.ConfigPersistence) error {
|
|
if persistence == nil {
|
|
return nil
|
|
}
|
|
baseDir, err := securityutil.JoinStorageLeaf(persistence.DataDir(), "reports")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
generatedDir, err := securityutil.JoinStorageLeaf(baseDir, "generated")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scheduleDirs, err := os.ReadDir(generatedDir)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, scheduleDir := range scheduleDirs {
|
|
if !scheduleDir.IsDir() || scheduleDir.Type()&os.ModeSymlink != 0 {
|
|
continue
|
|
}
|
|
dir, err := securityutil.JoinStorageLeaf(generatedDir, scheduleDir.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, file := range files {
|
|
if file.IsDir() || file.Type()&os.ModeSymlink != 0 {
|
|
continue
|
|
}
|
|
path, err := securityutil.JoinStorageLeaf(dir, file.Name())
|
|
if err == nil {
|
|
_ = os.Remove(path)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *ReportingHandlers) runDueReportSchedulesForOrg(ctx context.Context, orgID string, now time.Time) {
|
|
persistence, store, err := h.reportSchedulePersistenceAndStore(ctx)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: load store")
|
|
return
|
|
}
|
|
changed := false
|
|
for i := range store.Schedules {
|
|
schedule := config.NormalizeReportSchedule(store.Schedules[i])
|
|
if !schedule.Enabled {
|
|
continue
|
|
}
|
|
occurrence, key, err := lastReportScheduleOccurrenceAt(schedule, now)
|
|
if err != nil {
|
|
store.Schedules[i] = markReportScheduleFailed(schedule, now, err)
|
|
changed = true
|
|
continue
|
|
}
|
|
if key == "" || key == schedule.LastOccurrenceKey || occurrence.After(now) {
|
|
continue
|
|
}
|
|
if now.Sub(occurrence) > reportScheduleMissedRunGrace {
|
|
schedule.LastOccurrenceKey = key
|
|
if next, err := nextReportScheduleRunAt(schedule, now); err == nil {
|
|
schedule.NextRunAt = &next
|
|
}
|
|
schedule.UpdatedAt = now
|
|
store.Schedules[i] = schedule
|
|
changed = true
|
|
continue
|
|
}
|
|
_, updated := h.runReportSchedule(ctx, persistence, schedule, now, false, key)
|
|
store.Schedules[i] = updated
|
|
changed = true
|
|
}
|
|
if changed {
|
|
if err := persistence.SaveReportScheduleStore(*store); err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: save statuses")
|
|
}
|
|
}
|
|
}
|
|
|
|
// preparePatrolDigestSchedule validates the Patrol weekly summary kind. The
|
|
// summary is fleet-wide and only makes sense as a weekly email, so scope,
|
|
// format, attachments, and disk delivery are fixed rather than user choices.
|
|
func preparePatrolDigestSchedule(schedule config.ReportSchedule, now time.Time) (config.ReportSchedule, error) {
|
|
schedule.Format = config.ReportScheduleFormatEmail
|
|
schedule.Scope = config.ReportScheduleScope{Resources: []config.ReportScheduleResource{}, Tags: []string{}}
|
|
if schedule.Delivery.Method == "" {
|
|
schedule.Delivery.Method = config.ReportScheduleDeliveryEmail
|
|
}
|
|
if schedule.Delivery.Method != config.ReportScheduleDeliveryEmail {
|
|
return schedule, reportScheduleValidationError{code: "invalid_delivery", message: "Patrol weekly summaries are delivered by email"}
|
|
}
|
|
schedule.Delivery.Attach = false
|
|
schedule.Delivery.SaveToDisk = false
|
|
if schedule.Cadence.Type != config.ReportScheduleCadenceWeekly {
|
|
return schedule, reportScheduleValidationError{code: "invalid_cadence", message: "Patrol weekly summaries use a weekly cadence"}
|
|
}
|
|
if err := validateReportScheduleCadence(schedule.Cadence); err != nil {
|
|
return schedule, err
|
|
}
|
|
next, err := nextReportScheduleRunAt(schedule, now)
|
|
if err != nil {
|
|
return schedule, err
|
|
}
|
|
schedule.NextRunAt = &next
|
|
return schedule, nil
|
|
}
|
|
|
|
// runPatrolDigestSchedule emails the last seven days of Patrol work. Nothing is
|
|
// written to disk: the digest is a rollup of records Pulse already keeps, so
|
|
// the email is the only artifact.
|
|
func (h *ReportingHandlers) runPatrolDigestSchedule(ctx context.Context, persistence *config.ConfigPersistence, schedule config.ReportSchedule, now time.Time) (reportScheduleRunResult, config.ReportSchedule) {
|
|
result := reportScheduleRunResult{}
|
|
if h == nil || h.patrolDigestResolver == nil {
|
|
return result, markReportScheduleFailed(schedule, now, errors.New("Patrol weekly summary is unavailable on this server"))
|
|
}
|
|
digest, ok := h.patrolDigestResolver(ctx, ai.PatrolDigestDefaultDays)
|
|
if !ok {
|
|
return result, markReportScheduleFailed(schedule, now, errors.New("Patrol is not available for this workspace"))
|
|
}
|
|
manager, err := scheduledReportEmailManager(persistence, schedule)
|
|
if err != nil {
|
|
return result, markReportScheduleFailed(schedule, now, err)
|
|
}
|
|
if manager == nil {
|
|
return result, markReportScheduleFailed(schedule, now, errors.New("email notifications are not configured; add an email destination under Settings > Notifications or list recipients on the schedule"))
|
|
}
|
|
subject, htmlBody, textBody := renderPatrolDigestEmail(digest, h.patrolDigestPublicURL())
|
|
if err := manager.SendEmailWithRetry(subject, htmlBody, textBody); err != nil {
|
|
return result, markReportScheduleFailed(schedule, now, fmt.Errorf("send Patrol weekly summary: %w", err))
|
|
}
|
|
result.email = "sent"
|
|
schedule.LastRunStatus = config.ReportScheduleLastRunOK
|
|
schedule.LastError = ""
|
|
return result, schedule
|
|
}
|
|
|
|
func (h *ReportingHandlers) patrolDigestPublicURL() string {
|
|
if h == nil || h.settingsStore == nil {
|
|
return ""
|
|
}
|
|
settings, err := h.settingsStore.LoadSystemSettings()
|
|
if err != nil || settings == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimRight(strings.TrimSpace(settings.PublicURL), "/")
|
|
}
|