util.go
⎇
Raw
1// Package util holds small helpers shared across packages.
2package util
3
4import (
5 "net/url"
6 "regexp"
7 "strconv"
8 "strings"
9 "time"
10
11 "hearthforge/internal/db"
12)
13
14// dateLayouts are the timestamp shapes the views have to render. NowISO
15// writes RFC 3339 in UTC. Git dates (%ai / %ci) and stored commit dates carry
16// their own offset. The CI tables default created_at to SQLite's
17// datetime('now'), which has no offset and is UTC. Parse that value as UTC,
18// not as local time.
19var dateLayouts = []string{
20 time.RFC3339Nano,
21 "2006-01-02 15:04:05 -0700",
22 "Mon, 2 Jan 2006 15:04:05 -0700",
23 "2006-01-02 15:04:05",
24}
25
26// parseISO parses a timestamp and converts it to UTC.
27func parseISO(iso string) (time.Time, bool) {
28 for _, layout := range dateLayouts {
29 if t, err := time.Parse(layout, iso); err == nil {
30 return t.UTC(), true
31 }
32 }
33 return time.Time{}, false
34}
35
36// FormatDate renders a date as YYYY-MM-DD in UTC.
37// It returns the input unchanged when it cannot be parsed.
38func FormatDate(iso string) string {
39 t, ok := parseISO(iso)
40 if !ok {
41 return iso
42 }
43 return t.Format("2006-01-02")
44}
45
46// FormatDateTime renders a timestamp as "YYYY-MM-DD HH:MM:SS UTC".
47// It returns the input unchanged when it cannot be parsed.
48func FormatDateTime(iso string) string {
49 t, ok := parseISO(iso)
50 if !ok {
51 return iso
52 }
53 return t.Format("2006-01-02 15:04:05") + " UTC"
54}
55
56// unsafeHeaderChar reports characters that must not reach a quoted header value.
57func unsafeHeaderChar(r rune) bool {
58 return r < 0x20 || r == 0x7f
59}
60
61// ContentDisposition builds a safe Content-Disposition header value.
62// disposition is "inline" or "attachment". The ASCII filename is sanitised so
63// it cannot break out of the quoted string. A UTF-8 filename* parameter per
64// RFC 5987 carries the original name.
65func ContentDisposition(disposition, name string) string {
66 base := name
67 if i := strings.LastIndexAny(base, `/\`); i >= 0 {
68 base = base[i+1:]
69 }
70
71 ascii := strings.Map(func(r rune) rune {
72 if unsafeHeaderChar(r) || r == '"' || r == '\\' {
73 return '_'
74 }
75 return r
76 }, base)
77 if ascii == "" {
78 ascii = "file"
79 }
80
81 utf8Name := strings.Map(func(r rune) rune {
82 if unsafeHeaderChar(r) {
83 return '_'
84 }
85 return r
86 }, base)
87 return disposition + `; filename="` + ascii + `"; filename*=UTF-8''` + escapeRFC5987(utf8Name)
88}
89
90// escapeRFC5987 percent-encodes like JavaScript's encodeURIComponent, minus
91// the apostrophe: RFC 5987 uses "'" to separate charset, language and value,
92// so it stays escaped. url.PathEscape is not a substitute; it leaves ":@=&+$"
93// raw, which RFC 5987 does not allow.
94func escapeRFC5987(s string) string {
95 e := strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
96 // encodeURIComponent leaves these unescaped.
97 for _, p := range []struct{ from, to string }{
98 {"%21", "!"}, {"%28", "("}, {"%29", ")"}, {"%2A", "*"},
99 } {
100 e = strings.ReplaceAll(e, p.from, p.to)
101 }
102 return e
103}
104
105// FormatBytes renders a byte count for the views: "512 B", "1.5 KB", "2.0 MB".
106// Sizes above a gigabyte keep counting in MB.
107func FormatBytes(n int64) string {
108 switch {
109 case n < 1024:
110 return strconv.FormatInt(n, 10) + " B"
111 case n < 1024*1024:
112 return strconv.FormatFloat(float64(n)/1024, 'f', 1, 64) + " KB"
113 default:
114 return strconv.FormatFloat(float64(n)/(1024*1024), 'f', 1, 64) + " MB"
115 }
116}
117
118// Page holds the pagination state of a list view.
119type Page struct {
120 Page int
121 TotalPages int
122 Offset int
123}
124
125// Paginate clamps rawPage into [1, totalPages] so an out-of-range query string
126// lands on the last page instead of an empty offset.
127func Paginate(rawPage, totalCount, perPage int) Page {
128 totalPages := (totalCount + perPage - 1) / perPage
129 if totalPages < 1 {
130 totalPages = 1
131 }
132 page := min(max(rawPage, 1), totalPages)
133 return Page{Page: page, TotalPages: totalPages, Offset: (page - 1) * perPage}
134}
135
136// ParsePage reads a page number from a query string value. It returns 1 for
137// anything unparsable, matching the clamping in Paginate.
138func ParsePage(raw string) int {
139 n, err := strconv.Atoi(raw)
140 if err != nil || n < 1 {
141 return 1
142 }
143 return n
144}
145
146// LabelTextColor picks black or white text for a "#rrggbb" label background.
147func LabelTextColor(hex string) string {
148 v, err := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 32)
149 if err != nil {
150 return "#ffffff"
151 }
152 r := float64(v >> 16 & 0xff)
153 g := float64(v >> 8 & 0xff)
154 b := float64(v & 0xff)
155 if (0.299*r+0.587*g+0.114*b)/255 > 0.5 {
156 return "#000000"
157 }
158 return "#ffffff"
159}
160
161// DisplayName shows ownerDisplayName for the admin account and a placeholder
162// for a deleted user. Pass an empty username for a deleted user.
163func DisplayName(username, ownerDisplayName string) string {
164 switch username {
165 case "":
166 return "[Deleted user]"
167 case db.AdminUsername:
168 return ownerDisplayName
169 default:
170 return username
171 }
172}
173
174var validUsername = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
175
176func ValidUsername(s string) bool { return validUsername.MatchString(s) }
177
178var (
179 imageTagRe = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$`)
180 imagePathSegRe = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`)
181)
182
183// ValidImageTag reports whether tag is a valid container image tag.
184func ValidImageTag(tag string) bool { return imageTagRe.MatchString(tag) }
185
186// ValidImagePath reports whether image is a valid image path below a
187// repository: empty, or lowercase segments joined by "/".
188func ValidImagePath(image string) bool {
189 if image == "" {
190 return true
191 }
192 for _, seg := range strings.Split(image, "/") {
193 // The registry splits request paths on these words.
194 if !imagePathSegRe.MatchString(seg) || seg == "blobs" || seg == "manifests" || seg == "tags" {
195 return false
196 }
197 }
198 return true
199}
200