Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/notices/notices.go

83aa601b98007ead72afeadb131a93d5694e04ca · 6.4 KB · 221 lines raw

1 // Package notices holds operator-authored banners shown at the top of every
2 // page: a site-wide list plus a list per repository. Content lives in one
3 // YAML file that is watched for changes, so editing it never needs a
4 // rebuild, and the optional admin listener can rewrite it.
5 //
6 // This package deliberately does not import internal/core: it is plain
7 // config, and keeping it dependency-free lets both the module and the admin
8 // handler reach it without an import cycle.
9 package notices
10
11 import (
12 "bytes"
13 "errors"
14 "fmt"
15 "io"
16 "net/url"
17 "sort"
18 "strings"
19 "unicode/utf8"
20
21 "gopkg.in/yaml.v3"
22 )
23
24 // Severities. The value doubles as the CSS class suffix, so it is validated
25 // on every load and save; a typo must not become an unstyled banner.
26 const (
27 TypeCritical = "critical"
28 TypeWarning = "warning"
29 TypeInfo = "info"
30 TypeFix = "fix"
31 )
32
33 // Notice is one banner. Link and LinkText are optional; an empty Link
34 // renders no anchor.
35 type Notice struct {
36 Type string `yaml:"type"`
37 Title string `yaml:"title,omitempty"`
38 Body string `yaml:"body,omitempty"`
39 Link string `yaml:"link,omitempty"`
40 LinkText string `yaml:"link_text,omitempty"`
41 }
42
43 // Document is the on-disk shape. Global renders above every repo's list.
44 type Document struct {
45 Global []Notice `yaml:"global,omitempty"`
46 Repos map[string][]Notice `yaml:"repos,omitempty"`
47 }
48
49 // Snapshot is an immutable document plus the content hash identifying it.
50 // They are carried together so a reader can never see data and revision
51 // disagree.
52 type Snapshot struct {
53 Doc Document
54 Rev string
55 }
56
57 const (
58 maxNotices = 200
59 maxPerRepo = 50
60 maxTitle = 200
61 maxBody = 4000
62 maxLink = 2048
63 maxLinkText = 120
64 )
65
66 // typeAliases make hand-editing forgiving without letting an invalid type
67 // reach the template.
68 var typeAliases = map[string]string{
69 "critical": TypeCritical,
70 "warning": TypeWarning,
71 "warn": TypeWarning,
72 "info": TypeInfo,
73 "informational": TypeInfo,
74 "information": TypeInfo,
75 "fix": TypeFix,
76 "fixed": TypeFix,
77 }
78
79 func canonicalType(s string) (string, bool) {
80 t, ok := typeAliases[strings.ToLower(strings.TrimSpace(s))]
81 return t, ok
82 }
83
84 // validSlug mirrors a repository directory name: one path element, no
85 // separators. It keeps the repos map keys from becoming junk.
86 func validSlug(s string) bool {
87 if s == "" || s == "." || s == ".." {
88 return false
89 }
90 for _, r := range s {
91 switch {
92 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
93 r == '.', r == '_', r == '-':
94 default:
95 return false
96 }
97 }
98 return true
99 }
100
101 // checkLink allows a site-relative path and the http/https/mailto schemes.
102 // html/template would neutralise a javascript: URL anyway, but rejecting it
103 // here turns a silent #ZgotmplZ into a visible form error.
104 func checkLink(raw string) error {
105 if strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\x00\r\n") {
106 return fmt.Errorf("link %q is not allowed", raw)
107 }
108 u, err := url.Parse(raw)
109 if err != nil {
110 return fmt.Errorf("link %q: %w", raw, err)
111 }
112 if !u.IsAbs() {
113 return nil // relative path
114 }
115 switch strings.ToLower(u.Scheme) {
116 case "http", "https", "mailto":
117 return nil
118 default:
119 return fmt.Errorf("link %q: scheme %q is not allowed", raw, u.Scheme)
120 }
121 }
122
123 // validate trims and canonicalises in place and returns every problem it
124 // found, so the admin form can show all of them at once.
125 func validate(doc *Document) error {
126 var errs []error
127 total := 0
128 check := func(list []Notice, where string) []Notice {
129 for i := range list {
130 n := &list[i]
131 total++
132 if t, ok := canonicalType(n.Type); ok {
133 n.Type = t
134 } else {
135 errs = append(errs, fmt.Errorf("%s[%d]: type %q must be one of critical, warning, info, fix", where, i, n.Type))
136 }
137 n.Title = strings.TrimSpace(n.Title)
138 n.Body = strings.TrimRight(n.Body, "\n")
139 n.Link = strings.TrimSpace(n.Link)
140 n.LinkText = strings.TrimSpace(n.LinkText)
141 if n.Title == "" && n.Body == "" {
142 errs = append(errs, fmt.Errorf("%s[%d]: needs a title or a body", where, i))
143 }
144 switch {
145 case utf8.RuneCountInString(n.Title) > maxTitle:
146 errs = append(errs, fmt.Errorf("%s[%d]: title longer than %d characters", where, i, maxTitle))
147 case utf8.RuneCountInString(n.Body) > maxBody:
148 errs = append(errs, fmt.Errorf("%s[%d]: body longer than %d characters", where, i, maxBody))
149 case utf8.RuneCountInString(n.LinkText) > maxLinkText:
150 errs = append(errs, fmt.Errorf("%s[%d]: link text longer than %d characters", where, i, maxLinkText))
151 }
152 if len(n.Link) > maxLink {
153 errs = append(errs, fmt.Errorf("%s[%d]: link longer than %d bytes", where, i, maxLink))
154 }
155 if n.Link != "" {
156 if err := checkLink(n.Link); err != nil {
157 errs = append(errs, fmt.Errorf("%s[%d]: %w", where, i, err))
158 }
159 }
160 }
161 return list
162 }
163
164 doc.Global = check(doc.Global, "global")
165 slugs := make([]string, 0, len(doc.Repos))
166 for slug := range doc.Repos {
167 slugs = append(slugs, slug)
168 }
169 sort.Strings(slugs)
170 for _, slug := range slugs {
171 if !validSlug(slug) {
172 errs = append(errs, fmt.Errorf("repos: %q is not a valid repository slug", slug))
173 continue
174 }
175 if len(doc.Repos[slug]) > maxPerRepo {
176 errs = append(errs, fmt.Errorf("repos.%s: more than %d notices", slug, maxPerRepo))
177 }
178 doc.Repos[slug] = check(doc.Repos[slug], "repos."+slug)
179 }
180 if total > maxNotices {
181 errs = append(errs, fmt.Errorf("more than %d notices in total", maxNotices))
182 }
183 return errors.Join(errs...)
184 }
185
186 // parse decodes strictly: an unknown key is a typo the operator should see,
187 // not something silently ignored. An empty file is an empty document.
188 func parse(b []byte) (Document, error) {
189 var doc Document
190 dec := yaml.NewDecoder(bytes.NewReader(b))
191 dec.KnownFields(true)
192 if err := dec.Decode(&doc); err != nil {
193 if errors.Is(err, io.EOF) {
194 return Document{Repos: map[string][]Notice{}}, nil
195 }
196 return Document{}, fmt.Errorf("notices: %w", err)
197 }
198 if err := validate(&doc); err != nil {
199 return Document{}, err
200 }
201 if doc.Repos == nil {
202 doc.Repos = map[string][]Notice{}
203 }
204 return doc, nil
205 }
206
207 // marshal assumes the document has already been through validate, which is
208 // what canonicalises severity aliases and trims fields. Both callers (parse
209 // and Store.Set) validate first.
210 func marshal(doc Document) ([]byte, error) {
211 var buf bytes.Buffer
212 enc := yaml.NewEncoder(&buf)
213 enc.SetIndent(2)
214 if err := enc.Encode(doc); err != nil {
215 return nil, err
216 }
217 if err := enc.Close(); err != nil {
218 return nil, err
219 }
220 return buf.Bytes(), nil
221 }