Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
462 строки
18 KiB
Go
462 строки
18 KiB
Go
// 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 <meta name="robots" content="noindex"> 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()
|
|
}
|
|
}
|