// Package util holds small helpers shared across packages. package util import ( "net/url" "regexp" "strconv" "strings" "time" "hearthforge/internal/db" ) // dateLayouts are the timestamp shapes the views have to render. NowISO // writes RFC 3339 in UTC. Git dates (%ai / %ci) and stored commit dates carry // their own offset. The CI tables default created_at to SQLite's // datetime('now'), which has no offset and is UTC. Parse that value as UTC, // not as local time. var dateLayouts = []string{ time.RFC3339Nano, "2006-01-02 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 -0700", "2006-01-02 15:04:05", } // parseISO parses a timestamp and converts it to UTC. func parseISO(iso string) (time.Time, bool) { for _, layout := range dateLayouts { if t, err := time.Parse(layout, iso); err == nil { return t.UTC(), true } } return time.Time{}, false } // FormatDate renders a date as YYYY-MM-DD in UTC. // It returns the input unchanged when it cannot be parsed. func FormatDate(iso string) string { t, ok := parseISO(iso) if !ok { return iso } return t.Format("2006-01-02") } // FormatDateTime renders a timestamp as "YYYY-MM-DD HH:MM:SS UTC". // It returns the input unchanged when it cannot be parsed. func FormatDateTime(iso string) string { t, ok := parseISO(iso) if !ok { return iso } return t.Format("2006-01-02 15:04:05") + " UTC" } // unsafeHeaderChar reports characters that must not reach a quoted header value. func unsafeHeaderChar(r rune) bool { return r < 0x20 || r == 0x7f } // ContentDisposition builds a safe Content-Disposition header value. // disposition is "inline" or "attachment". The ASCII filename is sanitised so // it cannot break out of the quoted string. A UTF-8 filename* parameter per // RFC 5987 carries the original name. func ContentDisposition(disposition, name string) string { base := name if i := strings.LastIndexAny(base, `/\`); i >= 0 { base = base[i+1:] } ascii := strings.Map(func(r rune) rune { if unsafeHeaderChar(r) || r == '"' || r == '\\' { return '_' } return r }, base) if ascii == "" { ascii = "file" } utf8Name := strings.Map(func(r rune) rune { if unsafeHeaderChar(r) { return '_' } return r }, base) return disposition + `; filename="` + ascii + `"; filename*=UTF-8''` + escapeRFC5987(utf8Name) } // escapeRFC5987 percent-encodes like JavaScript's encodeURIComponent, minus // the apostrophe: RFC 5987 uses "'" to separate charset, language and value, // so it stays escaped. url.PathEscape is not a substitute; it leaves ":@=&+$" // raw, which RFC 5987 does not allow. func escapeRFC5987(s string) string { e := strings.ReplaceAll(url.QueryEscape(s), "+", "%20") // encodeURIComponent leaves these unescaped. for _, p := range []struct{ from, to string }{ {"%21", "!"}, {"%28", "("}, {"%29", ")"}, {"%2A", "*"}, } { e = strings.ReplaceAll(e, p.from, p.to) } return e } // FormatBytes renders a byte count for the views: "512 B", "1.5 KB", "2.0 MB". // Sizes above a gigabyte keep counting in MB. func FormatBytes(n int64) string { switch { case n < 1024: return strconv.FormatInt(n, 10) + " B" case n < 1024*1024: return strconv.FormatFloat(float64(n)/1024, 'f', 1, 64) + " KB" default: return strconv.FormatFloat(float64(n)/(1024*1024), 'f', 1, 64) + " MB" } } // Page holds the pagination state of a list view. type Page struct { Page int TotalPages int Offset int } // Paginate clamps rawPage into [1, totalPages] so an out-of-range query string // lands on the last page instead of an empty offset. func Paginate(rawPage, totalCount, perPage int) Page { totalPages := (totalCount + perPage - 1) / perPage if totalPages < 1 { totalPages = 1 } page := min(max(rawPage, 1), totalPages) return Page{Page: page, TotalPages: totalPages, Offset: (page - 1) * perPage} } // ParsePage reads a page number from a query string value. It returns 1 for // anything unparsable, matching the clamping in Paginate. func ParsePage(raw string) int { n, err := strconv.Atoi(raw) if err != nil || n < 1 { return 1 } return n } // LabelTextColor picks black or white text for a "#rrggbb" label background. func LabelTextColor(hex string) string { v, err := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 32) if err != nil { return "#ffffff" } r := float64(v >> 16 & 0xff) g := float64(v >> 8 & 0xff) b := float64(v & 0xff) if (0.299*r+0.587*g+0.114*b)/255 > 0.5 { return "#000000" } return "#ffffff" } // DisplayName shows ownerDisplayName for the admin account and a placeholder // for a deleted user. Pass an empty username for a deleted user. func DisplayName(username, ownerDisplayName string) string { switch username { case "": return "[Deleted user]" case db.AdminUsername: return ownerDisplayName default: return username } } var validUsername = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) func ValidUsername(s string) bool { return validUsername.MatchString(s) } var ( imageTagRe = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$`) imagePathSegRe = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) ) // ValidImageTag reports whether tag is a valid container image tag. func ValidImageTag(tag string) bool { return imageTagRe.MatchString(tag) } // ValidImagePath reports whether image is a valid image path below a // repository: empty, or lowercase segments joined by "/". func ValidImagePath(image string) bool { if image == "" { return true } for _, seg := range strings.Split(image, "/") { // The registry splits request paths on these words. if !imagePathSegRe.MatchString(seg) || seg == "blobs" || seg == "manifests" || seg == "tags" { return false } } return true }