// Package models — status page subsystem (docs/plans/status-pages.md). // // M0 ships the schema for status_pages and its five related tables // (subscribers, incidents, maintenance, domains). The M0 milestone is // read-only at the dashboard level — no editor and no public render yet — // but landing the schema now lets downstream milestones wire public // routes, editor flows, and the notifier→subscriber bridge without // further ALTER TABLE churn. M5 (custom domain) only fills in // status_page_domains rows; the table itself is reserved here so the // M5 migration is just data, not DDL. // // All tables follow the existing RSMon conventions: concerns.Model + // concerns.Timestamped + Audited mixins, gorm.DeletedAt for soft delete // on the top-level status_pages row, pq.Int64Array for the monitor_ids // bigint[] join columns (same shape as sites and the Check.Warnings // slice). Partial-unique indexes (slug, subscriber email) are added via // raw SQL in app/models/migrate.go because the GORM tag language cannot // express a WHERE deleted_at IS NULL predicate. package models import ( "context" "crypto/sha256" "errors" "fmt" "net" "os" "regexp" "strconv" "strings" "time" "github.com/lib/pq" "golang.org/x/net/idna" "gorm.io/gorm" "rocketgit.ru/rsmon/worker/app/models/concerns" "rocketgit.ru/rsmon/worker/config/credis" ) // Status page color defaults. Matches the existing landing-page primary // green and the accent blue used in the /settings UI, so a freshly // created page already blends in with the rest of the app. const ( statusPageDefaultPrimaryColor = "#62c600" statusPageDefaultAccentColor = "#1a73e8" statusPageDefaultHistoryDays = 90 statusPageMaxSlugLen = 64 statusPageMaxNameLen = 120 ) // Hex color regex — accepts #RGB and #RRGGBB. Centralized so the // controller/model validation agree on the same shape. var hexColorRegex = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) // StatusPageSlugRegex mirrors the slug format enforced in // StatusPage.NormalizeSlug / ValidateSlug. Lowercase alphanumerics and // dashes, must start and end with an alphanumeric. Length is checked // separately so the regex stays readable. var statusPageSlugRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`) // StatusPage represents a public status page owned by an account. One // account may own many pages (gated by plan in M2+); slugs are globally // unique because public URLs do not contain the account ID. Each page exposes // a curated subset of the account's monitors and a recent-incidents // feed. Soft-deleted rows remain in the table so the partial-unique // index on (account_id, slug) WHERE deleted_at IS NULL still rejects // duplicate slugs against historical records — see the comment on // StatusPagesAccountSlugUnique in migrate.go. type StatusPage struct { concerns.Model AccountID int64 `gorm:"type:bigint REFERENCES accounts(id);not null;index" json:"account_id"` Account *Account `json:"-"` Slug string `gorm:"size:64;not null;index" json:"slug"` Name string `gorm:"size:120;not null" json:"name"` Description *string `gorm:"type:text" json:"description,omitempty"` LogoURL *string `gorm:"size:255" json:"logo_url,omitempty"` PrimaryColor string `gorm:"size:7;not null;default:'#62c600'" json:"primary_color"` AccentColor string `gorm:"size:7;not null;default:'#1a73e8'" json:"accent_color"` // MonitorIDs is the curated subset of account monitors the page // exposes. Order is preserved so the dashboard list and the public // render show the same ordering. Stored as bigint[] to keep // monitor-to-page mapping lookup-free on the read path; M2 will // add a UI to maintain this set. MonitorIDs pq.Int64Array `gorm:"type:bigint[];not null;default:'{}'" json:"monitor_ids"` ShowUptimeBars bool `gorm:"not null;default:true" json:"show_uptime_bars"` ShowResponseTimes bool `gorm:"not null;default:true" json:"show_response_times"` ShowHistoryDays int `gorm:"not null;default:90" json:"show_history_days"` // PasswordHash is populated in M4 only. Stored at length 255 so // a future bcrypt cost bump does not need a column resize. PasswordHash *string `gorm:"size:255" json:"-"` // GATrackingID — Google Analytics 4 measurement ID; emitted by // the public renderer in M4. GATrackingID *string `gorm:"size:32" json:"ga_tracking_id,omitempty"` // NoIndex emits so the // page can be staged without polluting search indexes. NoIndex bool `gorm:"not null;default:false" json:"no_index"` // IsPublished gates the public /status/:slug render. Until M2 // ships the editor the default value keeps M0 pages invisible. IsPublished bool `gorm:"not null;default:false" json:"is_published"` // AutoOpenIncidents is opt-in so publishing a page does not change // existing alert behavior until an owner explicitly enables it. AutoOpenIncidents bool `gorm:"not null;default:false" json:"auto_open_incidents"` concerns.Timestamped Audited // DeletedAt is the GORM soft-delete marker. Using gorm.DeletedAt // rather than concerns.SoftDelete because the latter adds a // DeleterID users(id) FK that we do not yet need on status_pages. DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // TableName returns the explicit table name so GORM does not try to // pluralize to "status_pages" via inflection. The plural is already // correct; we declare it anyway for clarity. func (StatusPage) TableName() string { return "status_pages" } // StatusPageSubscriberKind — values stored in status_page_subscribers.kind. // "alert" subscribes to incident-driven notifications; "digest_daily" // receives the morning summary (M3). New kinds should be appended so // the JSON serializations stay stable. const ( StatusPageSubscriberKindAlert = "alert" StatusPageSubscriberKindDigestDaily = "digest_daily" ) // StatusPageSubscriber is a row in status_page_subscribers. Email is // stored verbatim (no citext) because the codebase already persists // contact emails as-is; case-insensitive uniqueness is enforced via // the partial unique index in migrate.go using lower(email). type StatusPageSubscriber struct { concerns.Model StatusPageID int64 `gorm:"type:bigint REFERENCES status_pages(id) ON DELETE CASCADE;not null;index" json:"status_page_id"` StatusPage *StatusPage `json:"-"` // ContactID is an internal delivery endpoint. It is never returned by public // subscription APIs; tasks use it to preserve the normal worker email path. ContactID *int64 `gorm:"type:bigint REFERENCES contacts(id) ON DELETE SET NULL;index" json:"-"` Email string `gorm:"size:255;not null" json:"email"` Kind string `gorm:"size:16;not null;default:'alert'" json:"kind"` ConfirmTokenHash string `gorm:"size:64" json:"-"` // LegacyConfirmToken is retained only for confirmation links issued before // token hashing shipped. It is cleared on first use or resend. LegacyConfirmToken *string `gorm:"column:confirm_token;size:255" json:"-"` TokenExpiresAt time.Time `json:"-"` UnsubscribeTokenHash string `gorm:"size:64;default:''" json:"-"` ConfirmedAt *time.Time `json:"confirmed_at,omitempty"` UnsubscribedAt *time.Time `json:"unsubscribed_at,omitempty"` concerns.Timestamped } // TableName returns the explicit status_page_subscribers table name. func (StatusPageSubscriber) TableName() string { return "status_page_subscribers" } // StatusPageIncident severity values. info = heads-up notices, warn = // degradation, crit = full outage. Used for color-coding in the public // render (M1) and for filtering in the dashboard list (M0). const ( StatusPageIncidentSeverityInfo = "info" StatusPageIncidentSeverityWarn = "warn" StatusPageIncidentSeverityCrit = "crit" ) // StatusPageIncident represents a single incident entry on a status // page. event_id is a soft link back to the existing Event model so // "auto-open on monitor error" can be wired later without a second // migration. posted_by_user_id is nullable so external integrations // can write incidents anonymously. type StatusPageIncident struct { concerns.Model StatusPageID int64 `gorm:"type:bigint REFERENCES status_pages(id) ON DELETE CASCADE;not null;index" json:"status_page_id"` StatusPage *StatusPage `json:"-"` EventID *int64 `gorm:"type:bigint REFERENCES events(id) ON DELETE SET NULL;index" json:"event_id,omitempty"` Title string `gorm:"size:200;not null" json:"title"` BodyMD string `gorm:"type:text" json:"body_md,omitempty"` Severity string `gorm:"size:16;not null;default:'info'" json:"severity"` StartedAt time.Time `gorm:"not null;index" json:"started_at"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` PostedByUserID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"posted_by_user_id,omitempty"` concerns.Timestamped } // TableName returns the explicit status_page_incidents table name. func (StatusPageIncident) TableName() string { return "status_page_incidents" } // StatusPageMaintenance is a scheduled maintenance window. The // monitor_ids column is the set of monitors the window covers; empty // means "all monitors on the page". type StatusPageMaintenance struct { concerns.Model StatusPageID int64 `gorm:"type:bigint REFERENCES status_pages(id) ON DELETE CASCADE;not null;index" json:"status_page_id"` StatusPage *StatusPage `json:"-"` Title string `gorm:"size:200;not null" json:"title"` Description string `gorm:"type:text" json:"description,omitempty"` StartsAt time.Time `gorm:"not null;index" json:"starts_at"` EndsAt time.Time `gorm:"not null" json:"ends_at"` MonitorIDs pq.Int64Array `gorm:"type:bigint[];not null;default:'{}'" json:"monitor_ids"` NotifySubscribers bool `gorm:"not null;default:true" json:"notify_subscribers"` concerns.Timestamped } // TableName returns the explicit status_page_maintenance table name. func (StatusPageMaintenance) TableName() string { return "status_page_maintenance" } // StatusPageDomain is the M5 custom-domain mapping. Reserved in M0 so // the table does not need to be created at M5 — only rows are written // then. domain is unique globally (CNAMEs are hostnames, they cannot // be reused across pages), txt_token is the value the user adds as a // DNS TXT record to prove ownership. type StatusPageDomain struct { concerns.Model StatusPageID int64 `gorm:"type:bigint REFERENCES status_pages(id) ON DELETE CASCADE;not null;index" json:"status_page_id"` StatusPage *StatusPage `json:"-"` Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"` VerifiedAt *time.Time `json:"verified_at,omitempty"` TXTToken string `gorm:"size:64;not null" json:"txt_token,omitempty"` VerifyError string `gorm:"type:text" json:"verify_error,omitempty"` concerns.Timestamped } // TableName returns the explicit status_page_domains table name. func (StatusPageDomain) TableName() string { return "status_page_domains" } // NormalizeStatusPageDomain accepts a hostname only. URLs, ports, IP literals, // wildcard names, and invalid IDNA are deliberately rejected before DNS work. func NormalizeStatusPageDomain(in string) (string, error) { domain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(in)), ".") if domain == "" || len(domain) > 253 || strings.ContainsAny(domain, "/:@") || net.ParseIP(domain) != nil { return "", errStatusPage("domain must be a hostname") } ascii, err := idna.Lookup.ToASCII(domain) if err != nil || ascii == "" || len(ascii) > 253 || !strings.Contains(ascii, ".") { return "", errStatusPage("domain must be a valid hostname") } for _, label := range strings.Split(ascii, ".") { if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { return "", errStatusPage("domain must be a valid hostname") } for _, r := range label { if r != '-' && (r < 'a' || r > 'z') && (r < '0' || r > '9') { return "", errStatusPage("domain must be a valid hostname") } } } return ascii, nil } // HashStatusPageToken keeps bearer-style subscription URLs out of the database. func HashStatusPageToken(token string) string { sum := sha256.Sum256([]byte(token)) return fmt.Sprintf("%x", sum[:]) } func (s *StatusPageSubscriber) TokenMatches(token string) bool { return s != nil && s.TokenExpiresAt.After(time.Now()) && s.ConfirmTokenHash == HashStatusPageToken(token) } func (s *StatusPageSubscriber) UnsubscribeTokenMatches(token string) bool { return s != nil && s.UnsubscribeTokenHash != "" && s.UnsubscribeTokenHash == HashStatusPageToken(token) } // StatusPagePlatformDomain and StatusPagePublicIPs are deployment-owned DNS // targets. Customer domains must point here before they can become routable. func StatusPagePlatformDomain() string { return strings.TrimSuffix(strings.ToLower(os.Getenv("STATUS_PAGE_PLATFORM_DOMAIN")), ".") } func StatusPagePublicIPs() []string { return strings.FieldsFunc(os.Getenv("STATUS_PAGE_PUBLIC_IPS"), func(r rune) bool { return r == ',' || r == ' ' }) } var ( statusPageLookupCNAME = net.LookupCNAME statusPageLookupHost = net.LookupHost statusPageLookupTXT = net.LookupTXT ) // VerifyStatusPageDomain performs the BYO-DNS preflight. A customer must keep // the ownership TXT record and point either a CNAME at the platform hostname or // an A/AAAA record at one of the explicitly configured public addresses. func VerifyStatusPageDomain(domain *StatusPageDomain) error { if domain == nil { return errStatusPage("domain is required") } want, err := NormalizeStatusPageDomain(domain.Domain) if err != nil { return err } platform := StatusPagePlatformDomain() publicIPs := StatusPagePublicIPs() if platform == "" && len(publicIPs) == 0 { return errStatusPage("custom domain verification is not configured") } matchedTarget := false if platform != "" { if cname, lookupErr := statusPageLookupCNAME(want); lookupErr == nil { matchedTarget = strings.TrimSuffix(strings.ToLower(cname), ".") == platform } } if !matchedTarget && len(publicIPs) > 0 { if hosts, lookupErr := statusPageLookupHost(want); lookupErr == nil { for _, host := range hosts { for _, allowed := range publicIPs { if host == allowed { matchedTarget = true } } } } } if !matchedTarget { return errStatusPage("DNS must contain the configured CNAME or public A/AAAA address") } txt, lookupErr := statusPageLookupTXT(want) if lookupErr != nil { return errStatusPage("ownership TXT record was not found") } for _, value := range txt { if value == "rsmon-verify="+domain.TXTToken { return nil } } return errStatusPage("ownership TXT record does not match") } // NormalizeStatusPageSlug lowercases and trims a candidate slug so the // global partial unique index on slug is satisfied // regardless of how the caller capitalizes the input. Returns the // empty string when the result would be unusable as a URL path; // callers should fall back to a name-derived slug in that case. func NormalizeStatusPageSlug(in string) string { slug := strings.ToLower(strings.TrimSpace(in)) return slug } // ValidateStatusPageSlug enforces the slug format we expose to users: // lowercase alphanumeric plus dash, must start and end with an // alphanumeric, max length 64. Used by the editor before save (M2). // Returns nil when the slug is acceptable. func ValidateStatusPageSlug(slug string) error { if slug == "" { return errStatusPage("slug is required") } if len(slug) > statusPageMaxSlugLen { return errStatusPage("slug is too long") } if !statusPageSlugRegex.MatchString(slug) { return errStatusPage("slug must be lowercase alphanumeric with dashes") } return nil } // ValidateStatusPageColors returns an error if either color is set but // not a valid CSS hex string. Empty strings fall back to the model // defaults when written via BeforeSave hooks. func ValidateStatusPageColors(primary, accent string) error { if primary != "" && !hexColorRegex.MatchString(primary) { return errStatusPage("primary_color must be #RGB or #RRGGBB") } if accent != "" && !hexColorRegex.MatchString(accent) { return errStatusPage("accent_color must be #RGB or #RRGGBB") } return nil } // BeforeSave is the GORM hook that fills in the canonical defaults // (colors, history days) so callers can pass an empty struct and still // get a usable page. Hook is also the single place where slugs are // normalized, so the unique index never has to chase trailing spaces. // The gorm.DB parameter is required by the hook signature but unused — // the validation here is purely local to the model. func (p *StatusPage) BeforeSave(_ *gorm.DB) error { if p == nil { return nil } p.Slug = NormalizeStatusPageSlug(p.Slug) if err := ValidateStatusPageSlug(p.Slug); err != nil { return err } if p.PrimaryColor == "" { p.PrimaryColor = statusPageDefaultPrimaryColor } if p.AccentColor == "" { p.AccentColor = statusPageDefaultAccentColor } if p.ShowHistoryDays == 0 { p.ShowHistoryDays = statusPageDefaultHistoryDays } return ValidateStatusPageColors(p.PrimaryColor, p.AccentColor) } // IsPublishedNow reports whether the page is publicly visible. M0 // always returns false because the editor (M2) is the only thing that // flips IsPublished to true; this helper centralizes that contract. func (p *StatusPage) IsPublishedNow() bool { return p != nil && p.IsPublished && !p.DeletedAt.Valid } // errStatusPage builds a validation error carrying the message. The // returned error is a plain error; controllers translate it into a // 422 response. func errStatusPage(msg string) error { if msg == "" { return errors.New("status_page: invalid") } return fmt.Errorf("status_page: %s", msg) } // IsActiveSubscriber returns true when the subscriber has confirmed and // has not unsubscribed. Used by the M3 incident-notification loop. func (s *StatusPageSubscriber) IsActiveSubscriber() bool { if s == nil { return false } return s.ConfirmedAt != nil && s.UnsubscribedAt == nil } func (s *StatusPageSubscriber) BeforeCreate(_ *gorm.DB) error { if s.TokenExpiresAt.IsZero() { s.TokenExpiresAt = time.Now().Add(24 * time.Hour) } if s.ConfirmTokenHash == "" { s.ConfirmTokenHash = HashStatusPageToken(fmt.Sprintf("legacy-%d-%s", time.Now().UnixNano(), s.Email)) } return nil } // InvalidateStatusPageCache removes the public HTML cache without making // Redis availability part of the monitor or management write path. func InvalidateStatusPageCache(pageID int64) { if credis.Redis != nil { _ = credis.Redis.Del(context.Background(), "statuspage:html:"+strconv.FormatInt(pageID, 10)).Err() } }