utils.go 12 KB

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