// Package notices holds operator-authored banners shown at the top of every // page: a site-wide list plus a list per repository. Content lives in one // YAML file that is watched for changes, so editing it never needs a // rebuild, and the optional admin listener can rewrite it. // // This package deliberately does not import internal/core: it is plain // config, and keeping it dependency-free lets both the module and the admin // handler reach it without an import cycle. package notices import ( "bytes" "errors" "fmt" "io" "net/url" "sort" "strings" "unicode/utf8" "gopkg.in/yaml.v3" ) // Severities. The value doubles as the CSS class suffix, so it is validated // on every load and save; a typo must not become an unstyled banner. const ( TypeCritical = "critical" TypeWarning = "warning" TypeInfo = "info" TypeFix = "fix" ) // Notice is one banner. Link and LinkText are optional; an empty Link // renders no anchor. type Notice struct { Type string `yaml:"type"` Title string `yaml:"title,omitempty"` Body string `yaml:"body,omitempty"` Link string `yaml:"link,omitempty"` LinkText string `yaml:"link_text,omitempty"` } // Document is the on-disk shape. Global renders above every repo's list. type Document struct { Global []Notice `yaml:"global,omitempty"` Repos map[string][]Notice `yaml:"repos,omitempty"` } // Snapshot is an immutable document plus the content hash identifying it. // They are carried together so a reader can never see data and revision // disagree. type Snapshot struct { Doc Document Rev string } const ( maxNotices = 200 maxPerRepo = 50 maxTitle = 200 maxBody = 4000 maxLink = 2048 maxLinkText = 120 ) // typeAliases make hand-editing forgiving without letting an invalid type // reach the template. var typeAliases = map[string]string{ "critical": TypeCritical, "warning": TypeWarning, "warn": TypeWarning, "info": TypeInfo, "informational": TypeInfo, "information": TypeInfo, "fix": TypeFix, "fixed": TypeFix, } func canonicalType(s string) (string, bool) { t, ok := typeAliases[strings.ToLower(strings.TrimSpace(s))] return t, ok } // validSlug mirrors a repository directory name: one path element, no // separators. It keeps the repos map keys from becoming junk. func validSlug(s string) bool { if s == "" || s == "." || s == ".." { return false } for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': default: return false } } return true } // checkLink allows a site-relative path and the http/https/mailto schemes. // html/template would neutralise a javascript: URL anyway, but rejecting it // here turns a silent #ZgotmplZ into a visible form error. func checkLink(raw string) error { if strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\x00\r\n") { return fmt.Errorf("link %q is not allowed", raw) } u, err := url.Parse(raw) if err != nil { return fmt.Errorf("link %q: %w", raw, err) } if !u.IsAbs() { return nil // relative path } switch strings.ToLower(u.Scheme) { case "http", "https", "mailto": return nil default: return fmt.Errorf("link %q: scheme %q is not allowed", raw, u.Scheme) } } // validate trims and canonicalises in place and returns every problem it // found, so the admin form can show all of them at once. func validate(doc *Document) error { var errs []error total := 0 check := func(list []Notice, where string) []Notice { for i := range list { n := &list[i] total++ if t, ok := canonicalType(n.Type); ok { n.Type = t } else { errs = append(errs, fmt.Errorf("%s[%d]: type %q must be one of critical, warning, info, fix", where, i, n.Type)) } n.Title = strings.TrimSpace(n.Title) n.Body = strings.TrimRight(n.Body, "\n") n.Link = strings.TrimSpace(n.Link) n.LinkText = strings.TrimSpace(n.LinkText) if n.Title == "" && n.Body == "" { errs = append(errs, fmt.Errorf("%s[%d]: needs a title or a body", where, i)) } switch { case utf8.RuneCountInString(n.Title) > maxTitle: errs = append(errs, fmt.Errorf("%s[%d]: title longer than %d characters", where, i, maxTitle)) case utf8.RuneCountInString(n.Body) > maxBody: errs = append(errs, fmt.Errorf("%s[%d]: body longer than %d characters", where, i, maxBody)) case utf8.RuneCountInString(n.LinkText) > maxLinkText: errs = append(errs, fmt.Errorf("%s[%d]: link text longer than %d characters", where, i, maxLinkText)) } if len(n.Link) > maxLink { errs = append(errs, fmt.Errorf("%s[%d]: link longer than %d bytes", where, i, maxLink)) } if n.Link != "" { if err := checkLink(n.Link); err != nil { errs = append(errs, fmt.Errorf("%s[%d]: %w", where, i, err)) } } } return list } doc.Global = check(doc.Global, "global") slugs := make([]string, 0, len(doc.Repos)) for slug := range doc.Repos { slugs = append(slugs, slug) } sort.Strings(slugs) for _, slug := range slugs { if !validSlug(slug) { errs = append(errs, fmt.Errorf("repos: %q is not a valid repository slug", slug)) continue } if len(doc.Repos[slug]) > maxPerRepo { errs = append(errs, fmt.Errorf("repos.%s: more than %d notices", slug, maxPerRepo)) } doc.Repos[slug] = check(doc.Repos[slug], "repos."+slug) } if total > maxNotices { errs = append(errs, fmt.Errorf("more than %d notices in total", maxNotices)) } return errors.Join(errs...) } // parse decodes strictly: an unknown key is a typo the operator should see, // not something silently ignored. An empty file is an empty document. func parse(b []byte) (Document, error) { var doc Document dec := yaml.NewDecoder(bytes.NewReader(b)) dec.KnownFields(true) if err := dec.Decode(&doc); err != nil { if errors.Is(err, io.EOF) { return Document{Repos: map[string][]Notice{}}, nil } return Document{}, fmt.Errorf("notices: %w", err) } if err := validate(&doc); err != nil { return Document{}, err } if doc.Repos == nil { doc.Repos = map[string][]Notice{} } return doc, nil } // marshal assumes the document has already been through validate, which is // what canonicalises severity aliases and trims fields. Both callers (parse // and Store.Set) validate first. func marshal(doc Document) ([]byte, error) { var buf bytes.Buffer enc := yaml.NewEncoder(&buf) enc.SetIndent(2) if err := enc.Encode(doc); err != nil { return nil, err } if err := enc.Close(); err != nil { return nil, err } return buf.Bytes(), nil }