util.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. package util
  2. import (
  3. "encoding/base64"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "github.com/gabriel-vasile/mimetype"
  8. "golang.org/x/term"
  9. "io"
  10. "math/rand"
  11. "os"
  12. "regexp"
  13. "strconv"
  14. "strings"
  15. "sync"
  16. "time"
  17. )
  18. const (
  19. randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
  20. )
  21. var (
  22. random = rand.New(rand.NewSource(time.Now().UnixNano()))
  23. randomMutex = sync.Mutex{}
  24. sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
  25. errInvalidPriority = errors.New("invalid priority")
  26. noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
  27. )
  28. // FileExists checks if a file exists, and returns true if it does
  29. func FileExists(filename string) bool {
  30. stat, _ := os.Stat(filename)
  31. return stat != nil
  32. }
  33. // Contains returns true if needle is contained in haystack
  34. func Contains[T comparable](haystack []T, needle T) bool {
  35. for _, s := range haystack {
  36. if s == needle {
  37. return true
  38. }
  39. }
  40. return false
  41. }
  42. // ContainsAll returns true if all needles are contained in haystack
  43. func ContainsAll[T comparable](haystack []T, needles []T) bool {
  44. matches := 0
  45. for _, s := range haystack {
  46. for _, needle := range needles {
  47. if s == needle {
  48. matches++
  49. }
  50. }
  51. }
  52. return matches == len(needles)
  53. }
  54. // SplitNoEmpty splits a string using strings.Split, but filters out empty strings
  55. func SplitNoEmpty(s string, sep string) []string {
  56. res := make([]string, 0)
  57. for _, r := range strings.Split(s, sep) {
  58. if r != "" {
  59. res = append(res, r)
  60. }
  61. }
  62. return res
  63. }
  64. // SplitKV splits a string into a key/value pair using a separator, and trimming space. If the separator
  65. // is not found, key is empty.
  66. func SplitKV(s string, sep string) (key string, value string) {
  67. kv := strings.SplitN(strings.TrimSpace(s), sep, 2)
  68. if len(kv) == 2 {
  69. return strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
  70. }
  71. return "", strings.TrimSpace(kv[0])
  72. }
  73. // LastString returns the last string in a slice, or def if s is empty
  74. func LastString(s []string, def string) string {
  75. if len(s) == 0 {
  76. return def
  77. }
  78. return s[len(s)-1]
  79. }
  80. // RandomString returns a random string with a given length
  81. func RandomString(length int) string {
  82. randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
  83. defer randomMutex.Unlock()
  84. b := make([]byte, length)
  85. for i := range b {
  86. b[i] = randomStringCharset[random.Intn(len(randomStringCharset))]
  87. }
  88. return string(b)
  89. }
  90. // ValidRandomString returns true if the given string matches the format created by RandomString
  91. func ValidRandomString(s string, length int) bool {
  92. if len(s) != length {
  93. return false
  94. }
  95. for _, c := range strings.Split(s, "") {
  96. if !strings.Contains(randomStringCharset, c) {
  97. return false
  98. }
  99. }
  100. return true
  101. }
  102. // ParsePriority parses a priority string into its equivalent integer value
  103. func ParsePriority(priority string) (int, error) {
  104. p := strings.TrimSpace(strings.ToLower(priority))
  105. switch p {
  106. case "":
  107. return 0, nil
  108. case "1", "min":
  109. return 1, nil
  110. case "2", "low":
  111. return 2, nil
  112. case "3", "default":
  113. return 3, nil
  114. case "4", "high":
  115. return 4, nil
  116. case "5", "max", "urgent":
  117. return 5, nil
  118. default:
  119. // Ignore new HTTP Priority header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
  120. // Cloudflare adds this to requests when forwarding to the backend (ntfy), so we just ignore it.
  121. if strings.HasPrefix(p, "u=") {
  122. return 3, nil
  123. }
  124. return 0, errInvalidPriority
  125. }
  126. }
  127. // PriorityString converts a priority number to a string
  128. func PriorityString(priority int) (string, error) {
  129. switch priority {
  130. case 0:
  131. return "default", nil
  132. case 1:
  133. return "min", nil
  134. case 2:
  135. return "low", nil
  136. case 3:
  137. return "default", nil
  138. case 4:
  139. return "high", nil
  140. case 5:
  141. return "max", nil
  142. default:
  143. return "", errInvalidPriority
  144. }
  145. }
  146. // ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https://
  147. func ShortTopicURL(s string) string {
  148. return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
  149. }
  150. // ExpandTopicURL expands a topic to a fully qualified URL, e.g. "mytopic" -> "https://ntfy.sh/mytopic"
  151. func ExpandTopicURL(topic, defaultHost string) string {
  152. if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
  153. return topic
  154. } else if strings.Contains(topic, "/") {
  155. return fmt.Sprintf("https://%s", topic)
  156. }
  157. return fmt.Sprintf("%s/%s", defaultHost, topic)
  158. }
  159. // DetectContentType probes the byte array b and returns mime type and file extension.
  160. // The filename is only used to override certain special cases.
  161. func DetectContentType(b []byte, filename string) (mimeType string, ext string) {
  162. if strings.HasSuffix(strings.ToLower(filename), ".apk") {
  163. return "application/vnd.android.package-archive", ".apk"
  164. } else if strings.HasSuffix(strings.ToLower(filename), ".jwe") {
  165. return "application/jose", ".jwe"
  166. }
  167. m := mimetype.Detect(b)
  168. mimeType, ext = m.String(), m.Extension()
  169. if ext == "" {
  170. ext = ".bin"
  171. }
  172. return
  173. }
  174. // ParseSize parses a size string like 2K or 2M into bytes. If no unit is found, e.g. 123, bytes is assumed.
  175. func ParseSize(s string) (int64, error) {
  176. matches := sizeStrRegex.FindStringSubmatch(s)
  177. if matches == nil {
  178. return -1, fmt.Errorf("invalid size %s", s)
  179. }
  180. value, err := strconv.Atoi(matches[1])
  181. if err != nil {
  182. return -1, fmt.Errorf("cannot convert number %s", matches[1])
  183. }
  184. switch strings.ToUpper(matches[2]) {
  185. case "G":
  186. return int64(value) * 1024 * 1024 * 1024, nil
  187. case "M":
  188. return int64(value) * 1024 * 1024, nil
  189. case "K":
  190. return int64(value) * 1024, nil
  191. default:
  192. return int64(value), nil
  193. }
  194. }
  195. // ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the
  196. // input characters to the screen. If not, it'll just read using normal readline semantics (useful for testing).
  197. func ReadPassword(in io.Reader) ([]byte, error) {
  198. // If in is a file and a character device (a TTY), use term.ReadPassword
  199. if f, ok := in.(*os.File); ok {
  200. stat, err := f.Stat()
  201. if err != nil {
  202. return nil, err
  203. }
  204. if (stat.Mode() & os.ModeCharDevice) == os.ModeCharDevice {
  205. password, err := term.ReadPassword(int(f.Fd())) // This is always going to be 0
  206. if err != nil {
  207. return nil, err
  208. }
  209. return password, nil
  210. }
  211. }
  212. // Fallback: Manually read util \n if found, see #69 for details why this is so manual
  213. password := make([]byte, 0)
  214. buf := make([]byte, 1)
  215. for {
  216. _, err := in.Read(buf)
  217. if err == io.EOF || buf[0] == '\n' {
  218. break
  219. } else if err != nil {
  220. return nil, err
  221. } else if len(password) > 10240 {
  222. return nil, errors.New("passwords this long are not supported")
  223. }
  224. password = append(password, buf[0])
  225. }
  226. return password, nil
  227. }
  228. // BasicAuth encodes the Authorization header value for basic auth
  229. func BasicAuth(user, pass string) string {
  230. return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
  231. }
  232. // MaybeMarshalJSON returns a JSON string of the given object, or "<cannot serialize>" if serialization failed.
  233. // This is useful for logging purposes where a failure doesn't matter that much.
  234. func MaybeMarshalJSON(v any) string {
  235. jsonBytes, err := json.MarshalIndent(v, "", " ")
  236. if err != nil {
  237. return "<cannot serialize>"
  238. }
  239. if len(jsonBytes) > 5000 {
  240. return string(jsonBytes)[:5000]
  241. }
  242. return string(jsonBytes)
  243. }
  244. // QuoteCommand combines a command array to a string, quoting arguments that need quoting.
  245. // This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.
  246. //
  247. // Warning: Never use this function with the intent to run the resulting command.
  248. //
  249. // Example:
  250. //
  251. // []string{"ls", "-al", "Document Folder"} -> ls -al "Document Folder"
  252. func QuoteCommand(command []string) string {
  253. var quoted []string
  254. for _, c := range command {
  255. if noQuotesRegex.MatchString(c) {
  256. quoted = append(quoted, c)
  257. } else {
  258. quoted = append(quoted, fmt.Sprintf(`"%s"`, c))
  259. }
  260. }
  261. return strings.Join(quoted, " ")
  262. }