utils.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. package utils
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "math/rand"
  9. "net/url"
  10. "os"
  11. "os/exec"
  12. "path"
  13. "path/filepath"
  14. "regexp"
  15. "strings"
  16. "time"
  17. log "github.com/sirupsen/logrus"
  18. "github.com/yuin/goldmark"
  19. "github.com/yuin/goldmark/extension"
  20. "github.com/yuin/goldmark/renderer/html"
  21. "mvdan.cc/xurls"
  22. )
  23. // DoesFileExists checks if the file exists.
  24. func DoesFileExists(name string) bool {
  25. if _, err := os.Stat(name); err == nil {
  26. return true
  27. } else if os.IsNotExist(err) {
  28. return false
  29. } else {
  30. log.Errorln(err)
  31. return false
  32. }
  33. }
  34. // GetRelativePathFromAbsolutePath gets the relative path from the provided absolute path.
  35. func GetRelativePathFromAbsolutePath(path string) string {
  36. pathComponents := strings.Split(path, "/")
  37. variant := pathComponents[len(pathComponents)-2]
  38. file := pathComponents[len(pathComponents)-1]
  39. return filepath.Join(variant, file)
  40. }
  41. // GetIndexFromFilePath is a utility that will return the index/key/variant name in a full path.
  42. func GetIndexFromFilePath(path string) string {
  43. pathComponents := strings.Split(path, "/")
  44. variant := pathComponents[len(pathComponents)-2]
  45. return variant
  46. }
  47. // Copy copies the file to destination.
  48. func Copy(source, destination string) error {
  49. input, err := os.ReadFile(source) // nolint
  50. if err != nil {
  51. return err
  52. }
  53. return os.WriteFile(destination, input, 0o600)
  54. }
  55. // Move moves the file at source to destination.
  56. func Move(source, destination string) error {
  57. err := os.Rename(source, destination)
  58. if err != nil {
  59. log.Warnln("Moving with os.Rename failed, falling back to copy and delete!", err)
  60. return moveFallback(source, destination)
  61. }
  62. return nil
  63. }
  64. // moveFallback moves a file using a copy followed by a delete, which works across file systems.
  65. // source: https://gist.github.com/var23rav/23ae5d0d4d830aff886c3c970b8f6c6b
  66. func moveFallback(source, destination string) error {
  67. inputFile, err := os.Open(source) // nolint: gosec
  68. if err != nil {
  69. return fmt.Errorf("Couldn't open source file: %s", err)
  70. }
  71. outputFile, err := os.Create(destination) // nolint: gosec
  72. if err != nil {
  73. _ = inputFile.Close()
  74. return fmt.Errorf("Couldn't open dest file: %s", err)
  75. }
  76. defer outputFile.Close()
  77. _, err = io.Copy(outputFile, inputFile)
  78. _ = inputFile.Close()
  79. if err != nil {
  80. return fmt.Errorf("Writing to output file failed: %s", err)
  81. }
  82. // The copy was successful, so now delete the original file
  83. err = os.Remove(source)
  84. if err != nil {
  85. return fmt.Errorf("Failed removing original file: %s", err)
  86. }
  87. return nil
  88. }
  89. // IsUserAgentAPlayer returns if a web client user-agent is seen as a media player.
  90. func IsUserAgentAPlayer(userAgent string) bool {
  91. if userAgent == "" {
  92. return false
  93. }
  94. playerStrings := []string{
  95. "mpv",
  96. "player",
  97. "vlc",
  98. "applecoremedia",
  99. }
  100. for _, playerString := range playerStrings {
  101. if strings.Contains(strings.ToLower(userAgent), playerString) {
  102. return true
  103. }
  104. }
  105. return false
  106. }
  107. // RenderSimpleMarkdown will return HTML without sanitization or specific formatting rules.
  108. func RenderSimpleMarkdown(raw string) string {
  109. markdown := goldmark.New(
  110. goldmark.WithRendererOptions(
  111. html.WithUnsafe(),
  112. ),
  113. goldmark.WithExtensions(
  114. extension.NewLinkify(
  115. extension.WithLinkifyAllowedProtocols([][]byte{
  116. []byte("http:"),
  117. []byte("https:"),
  118. }),
  119. extension.WithLinkifyURLRegexp(
  120. xurls.Strict,
  121. ),
  122. ),
  123. ),
  124. )
  125. trimmed := strings.TrimSpace(raw)
  126. var buf bytes.Buffer
  127. if err := markdown.Convert([]byte(trimmed), &buf); err != nil {
  128. log.Fatalln(err)
  129. }
  130. return buf.String()
  131. }
  132. // RenderPageContentMarkdown will return HTML specifically handled for the user-specified page content.
  133. func RenderPageContentMarkdown(raw string) string {
  134. markdown := goldmark.New(
  135. goldmark.WithRendererOptions(
  136. html.WithUnsafe(),
  137. ),
  138. goldmark.WithExtensions(
  139. extension.GFM,
  140. extension.NewLinkify(
  141. extension.WithLinkifyAllowedProtocols([][]byte{
  142. []byte("http:"),
  143. []byte("https:"),
  144. }),
  145. extension.WithLinkifyURLRegexp(
  146. xurls.Strict,
  147. ),
  148. ),
  149. ),
  150. )
  151. trimmed := strings.TrimSpace(raw)
  152. var buf bytes.Buffer
  153. if err := markdown.Convert([]byte(trimmed), &buf); err != nil {
  154. log.Fatalln(err)
  155. }
  156. return strings.TrimSpace(buf.String())
  157. }
  158. // GetCacheDurationSecondsForPath will return the number of seconds to cache an item.
  159. func GetCacheDurationSecondsForPath(filePath string) int {
  160. filename := path.Base(filePath)
  161. fileExtension := path.Ext(filePath)
  162. defaultDaysCached := 30
  163. if filename == "thumbnail.jpg" || filename == "preview.gif" {
  164. // Thumbnails & preview gif re-generate during live
  165. return 20
  166. } else if fileExtension == ".js" || fileExtension == ".css" {
  167. // Cache javascript & CSS
  168. return 60 * 60 * 24 * defaultDaysCached
  169. } else if fileExtension == ".ts" || fileExtension == ".woff2" {
  170. // Cache video segments as long as you want. They can't change.
  171. // This matters most for local hosting of segments for recordings
  172. // and not for live or 3rd party storage.
  173. return 31557600
  174. } else if fileExtension == ".m3u8" {
  175. return 0
  176. } else if fileExtension == ".jpg" || fileExtension == ".png" || fileExtension == ".gif" || fileExtension == ".svg" {
  177. return 60 * 60 * 24 * defaultDaysCached
  178. } else if fileExtension == ".html" || filename == "/" || fileExtension == "" {
  179. return 0
  180. }
  181. // Default cache length in seconds
  182. return 60 * 60 * 24 * 1 // For unknown types, cache for 1 day
  183. }
  184. // IsValidURL will return if a URL string is a valid URL or not.
  185. func IsValidURL(urlToTest string) bool {
  186. if _, err := url.ParseRequestURI(urlToTest); err != nil {
  187. return false
  188. }
  189. u, err := url.Parse(urlToTest)
  190. if err != nil || u.Scheme == "" || u.Host == "" {
  191. return false
  192. }
  193. return true
  194. }
  195. // ValidatedFfmpegPath will take a proposed path to ffmpeg and return a validated path.
  196. func ValidatedFfmpegPath(ffmpegPath string) string {
  197. if ffmpegPath != "" {
  198. if err := VerifyFFMpegPath(ffmpegPath); err == nil {
  199. return ffmpegPath
  200. }
  201. log.Warnln(ffmpegPath, "is an invalid path to ffmpeg will try to use a copy in your path, if possible")
  202. }
  203. // First look to see if ffmpeg is in the current working directory
  204. localCopy := "./ffmpeg"
  205. hasLocalCopyError := VerifyFFMpegPath(localCopy)
  206. if hasLocalCopyError == nil {
  207. // No error, so all is good. Use the local copy.
  208. return localCopy
  209. }
  210. cmd := exec.Command("which", "ffmpeg")
  211. out, err := cmd.CombinedOutput()
  212. if err != nil {
  213. log.Fatalln("Unable to locate ffmpeg. Either install it globally on your system or put the ffmpeg binary in the same directory as Owncast. The binary must be named ffmpeg.")
  214. }
  215. path := strings.TrimSpace(string(out))
  216. return path
  217. }
  218. // VerifyFFMpegPath verifies that the path exists, is a file, and is executable.
  219. func VerifyFFMpegPath(path string) error {
  220. stat, err := os.Stat(path)
  221. if os.IsNotExist(err) {
  222. return errors.New("ffmpeg path does not exist")
  223. }
  224. if err != nil {
  225. return fmt.Errorf("error while verifying the ffmpeg path: %s", err.Error())
  226. }
  227. if stat.IsDir() {
  228. return errors.New("ffmpeg path can not be a folder")
  229. }
  230. mode := stat.Mode()
  231. // source: https://stackoverflow.com/a/60128480
  232. if mode&0o111 == 0 {
  233. return errors.New("ffmpeg path is not executable")
  234. }
  235. return nil
  236. }
  237. // CleanupDirectory removes the directory and makes it fresh again. Throws fatal error on failure.
  238. func CleanupDirectory(path string) {
  239. log.Traceln("Cleaning", path)
  240. if err := os.RemoveAll(path); err != nil {
  241. log.Fatalln("Unable to remove directory. Please check the ownership and permissions", err)
  242. }
  243. if err := os.MkdirAll(path, 0o750); err != nil {
  244. log.Fatalln("Unable to create directory. Please check the ownership and permissions", err)
  245. }
  246. }
  247. // FindInSlice will return if a string is in a slice, and the index of that string.
  248. func FindInSlice(slice []string, val string) (int, bool) {
  249. for i, item := range slice {
  250. if item == val {
  251. return i, true
  252. }
  253. }
  254. return -1, false
  255. }
  256. // StringSliceToMap is a convenience function to convert a slice of strings into
  257. // a map using the string as the key.
  258. func StringSliceToMap(stringSlice []string) map[string]interface{} {
  259. stringMap := map[string]interface{}{}
  260. for _, str := range stringSlice {
  261. stringMap[str] = true
  262. }
  263. return stringMap
  264. }
  265. // Float64MapToSlice is a convenience function to convert a map of floats into.
  266. func Float64MapToSlice(float64Map map[string]float64) []float64 {
  267. float64Slice := []float64{}
  268. for _, val := range float64Map {
  269. float64Slice = append(float64Slice, val)
  270. }
  271. return float64Slice
  272. }
  273. // StringMapKeys returns a slice of string keys from a map.
  274. func StringMapKeys(stringMap map[string]interface{}) []string {
  275. stringSlice := []string{}
  276. for k := range stringMap {
  277. stringSlice = append(stringSlice, k)
  278. }
  279. return stringSlice
  280. }
  281. // GenerateRandomDisplayColor will return a random number that is used for
  282. // referencing a color value client-side. These colors are seen as
  283. // --theme-user-colors-n.
  284. func GenerateRandomDisplayColor(maxColor int) int {
  285. rangeLower := 0
  286. rangeUpper := maxColor
  287. return rangeLower + rand.Intn(rangeUpper-rangeLower+1) //nolint:gosec
  288. }
  289. // GetHostnameFromURL will return the hostname component from a URL string.
  290. func GetHostnameFromURL(u url.URL) string {
  291. return u.Host
  292. }
  293. // GetHostnameFromURLString will return the hostname component from a URL object.
  294. func GetHostnameFromURLString(s string) string {
  295. u, err := url.Parse(s)
  296. if err != nil {
  297. return ""
  298. }
  299. return u.Host
  300. }
  301. // GetHashtagsFromText returns all the #Hashtags from a string.
  302. func GetHashtagsFromText(text string) []string {
  303. re := regexp.MustCompile(`#[a-zA-Z0-9_]+`)
  304. return re.FindAllString(text, -1)
  305. }
  306. // ShuffleStringSlice will shuffle a slice of strings.
  307. func ShuffleStringSlice(s []string) []string {
  308. rand.Seed(time.Now().UnixNano())
  309. rand.Shuffle(len(s), func(i, j int) {
  310. s[i], s[j] = s[j], s[i]
  311. })
  312. return s
  313. }
  314. // IntPercentage returns an int percentage of a number.
  315. func IntPercentage(x, total int) int {
  316. return int(float64(x) / float64(total) * 100)
  317. }
  318. // DecodeBase64Image decodes a base64 image string into a byte array, returning the extension (including dot) for the content type.
  319. func DecodeBase64Image(url string) (bytes []byte, extension string, err error) {
  320. s := strings.SplitN(url, ",", 2)
  321. if len(s) < 2 {
  322. err = errors.New("error splitting base64 image data")
  323. return
  324. }
  325. bytes, err = base64.StdEncoding.DecodeString(s[1])
  326. if err != nil {
  327. return
  328. }
  329. splitHeader := strings.Split(s[0], ":")
  330. if len(splitHeader) < 2 {
  331. err = errors.New("error splitting base64 image header")
  332. return
  333. }
  334. contentType := strings.Split(splitHeader[1], ";")[0]
  335. if contentType == "image/svg+xml" {
  336. extension = ".svg"
  337. } else if contentType == "image/gif" {
  338. extension = ".gif"
  339. } else if contentType == "image/png" {
  340. extension = ".png"
  341. } else if contentType == "image/jpeg" {
  342. extension = ".jpeg"
  343. }
  344. if extension == "" {
  345. err = errors.New("missing or invalid contentType in base64 image")
  346. return
  347. }
  348. return bytes, extension, nil
  349. }