package utils import ( "bytes" "encoding/base64" "errors" "fmt" "io" "math" "math/rand" "net/url" "os" "os/exec" "path" "path/filepath" "regexp" "strings" "time" "github.com/mssola/user_agent" log "github.com/sirupsen/logrus" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" "mvdan.cc/xurls" ) // DoesFileExists checks if the file exists. func DoesFileExists(name string) bool { if _, err := os.Stat(name); err == nil { return true } else if os.IsNotExist(err) { return false } else { log.Errorln(err) return false } } // GetRelativePathFromAbsolutePath gets the relative path from the provided absolute path. func GetRelativePathFromAbsolutePath(path string) string { pathComponents := strings.Split(path, "/") variant := pathComponents[len(pathComponents)-2] file := pathComponents[len(pathComponents)-1] return filepath.Join(variant, file) } // GetIndexFromFilePath is a utility that will return the index/key/variant name in a full path. func GetIndexFromFilePath(path string) string { pathComponents := strings.Split(path, "/") variant := pathComponents[len(pathComponents)-2] return variant } // Copy copies the file to destination. func Copy(source, destination string) error { input, err := os.ReadFile(source) // nolint if err != nil { return err } return os.WriteFile(destination, input, 0o600) } // Move moves the file at source to destination. func Move(source, destination string) error { err := os.Rename(source, destination) if err != nil { log.Debugln("Moving with os.Rename failed, falling back to copy and delete!", err) return moveFallback(source, destination) } return nil } // moveFallback moves a file using a copy followed by a delete, which works across file systems. // source: https://gist.github.com/var23rav/23ae5d0d4d830aff886c3c970b8f6c6b func moveFallback(source, destination string) error { inputFile, err := os.Open(source) // nolint: gosec if err != nil { return fmt.Errorf("Couldn't open source file: %s", err) } outputFile, err := os.Create(destination) // nolint: gosec if err != nil { _ = inputFile.Close() return fmt.Errorf("Couldn't open dest file: %s", err) } defer outputFile.Close() _, err = io.Copy(outputFile, inputFile) _ = inputFile.Close() if err != nil { return fmt.Errorf("Writing to output file failed: %s", err) } // The copy was successful, so now delete the original file err = os.Remove(source) if err != nil { return fmt.Errorf("Failed removing original file: %s", err) } return nil } // IsUserAgentAPlayer returns if a web client user-agent is seen as a media player. func IsUserAgentAPlayer(userAgent string) bool { if userAgent == "" { return false } playerStrings := []string{ "mpv", "player", "vlc", "applecoremedia", } for _, playerString := range playerStrings { if strings.Contains(strings.ToLower(userAgent), playerString) { return true } } return false } // IsUserAgentABot returns if a web client user-agent is seen as a bot. func IsUserAgentABot(userAgent string) bool { if userAgent == "" { return false } botStrings := []string{ "mastodon", "pleroma", "applebot", "whatsapp", "matrix", "synapse", "element", "rocket.chat", "duckduckbot", } for _, botString := range botStrings { if strings.Contains(strings.ToLower(userAgent), botString) { return true } } ua := user_agent.New(userAgent) return ua.Bot() } // RenderSimpleMarkdown will return HTML without sanitization or specific formatting rules. func RenderSimpleMarkdown(raw string) string { markdown := goldmark.New( goldmark.WithRendererOptions( html.WithUnsafe(), ), goldmark.WithExtensions( extension.NewLinkify( extension.WithLinkifyAllowedProtocols([][]byte{ []byte("http:"), []byte("https:"), }), extension.WithLinkifyURLRegexp( xurls.Strict, ), ), ), ) trimmed := strings.TrimSpace(raw) var buf bytes.Buffer if err := markdown.Convert([]byte(trimmed), &buf); err != nil { log.Fatalln(err) } return strings.TrimSpace(buf.String()) } // RenderPageContentMarkdown will return HTML specifically handled for the user-specified page content. func RenderPageContentMarkdown(raw string) string { markdown := goldmark.New( goldmark.WithRendererOptions( html.WithUnsafe(), ), goldmark.WithExtensions( extension.GFM, extension.NewLinkify( extension.WithLinkifyAllowedProtocols([][]byte{ []byte("http:"), []byte("https:"), }), extension.WithLinkifyURLRegexp( xurls.Strict, ), ), ), ) trimmed := strings.TrimSpace(raw) var buf bytes.Buffer if err := markdown.Convert([]byte(trimmed), &buf); err != nil { log.Fatalln(err) } return strings.TrimSpace(buf.String()) } // GetCacheDurationSecondsForPath will return the number of seconds to cache an item. func GetCacheDurationSecondsForPath(filePath string) int { filename := path.Base(filePath) fileExtension := path.Ext(filePath) defaultDaysCached := 30 if filename == "thumbnail.jpg" || filename == "preview.gif" { // Thumbnails & preview gif re-generate during live return 20 } else if fileExtension == ".js" || fileExtension == ".css" { // Cache javascript & CSS return 60 * 60 * 24 * defaultDaysCached } else if fileExtension == ".ts" || fileExtension == ".woff2" { // Cache video segments as long as you want. They can't change. // This matters most for local hosting of segments for recordings // and not for live or 3rd party storage. return 31557600 } else if fileExtension == ".m3u8" { return 0 } else if fileExtension == ".jpg" || fileExtension == ".png" || fileExtension == ".gif" || fileExtension == ".svg" { return 60 * 60 * 24 * defaultDaysCached } else if fileExtension == ".html" || filename == "/" || fileExtension == "" { return 0 } // Default cache length in seconds return 60 * 60 * 24 * 1 // For unknown types, cache for 1 day } // IsValidURL will return if a URL string is a valid URL or not. func IsValidURL(urlToTest string) bool { if _, err := url.ParseRequestURI(urlToTest); err != nil { return false } u, err := url.Parse(urlToTest) if err != nil || u.Scheme == "" || u.Host == "" { return false } return true } // ValidatedFfmpegPath will take a proposed path to ffmpeg and return a validated path. func ValidatedFfmpegPath(ffmpegPath string) string { if ffmpegPath != "" { if err := VerifyFFMpegPath(ffmpegPath); err == nil { return ffmpegPath } log.Warnln(ffmpegPath, "is an invalid path to ffmpeg will try to use a copy in your path, if possible") } // First look to see if ffmpeg is in the current working directory localCopy := "./ffmpeg" hasLocalCopyError := VerifyFFMpegPath(localCopy) if hasLocalCopyError == nil { // No error, so all is good. Use the local copy. return localCopy } cmd := exec.Command("which", "ffmpeg") out, err := cmd.CombinedOutput() if err != nil { 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.") } path := strings.TrimSpace(string(out)) return path } // VerifyFFMpegPath verifies that the path exists, is a file, and is executable. func VerifyFFMpegPath(path string) error { stat, err := os.Stat(path) if os.IsNotExist(err) { return errors.New("ffmpeg path does not exist") } if err != nil { return fmt.Errorf("error while verifying the ffmpeg path: %s", err.Error()) } if stat.IsDir() { return errors.New("ffmpeg path can not be a folder") } mode := stat.Mode() // source: https://stackoverflow.com/a/60128480 if mode&0o111 == 0 { return errors.New("ffmpeg path is not executable") } return nil } // CleanupDirectory removes the directory and makes it fresh again. Throws fatal error on failure. func CleanupDirectory(path string, keepOldFiles bool) { if !keepOldFiles { log.Traceln("Cleaning", path) if err := os.RemoveAll(path); err != nil { log.Fatalln("Unable to remove directory. Please check the ownership and permissions", err) } } if err := os.MkdirAll(path, 0o750); err != nil { log.Fatalln("Unable to create directory. Please check the ownership and permissions", err) } } // FindInSlice will return if a string is in a slice, and the index of that string. func FindInSlice(slice []string, val string) (int, bool) { for i, item := range slice { if item == val { return i, true } } return -1, false } // StringSliceToMap is a convenience function to convert a slice of strings into // a map using the string as the key. func StringSliceToMap(stringSlice []string) map[string]interface{} { stringMap := map[string]interface{}{} for _, str := range stringSlice { stringMap[str] = true } return stringMap } // Float64MapToSlice is a convenience function to convert a map of floats into. func Float64MapToSlice(float64Map map[string]float64) []float64 { float64Slice := []float64{} for _, val := range float64Map { float64Slice = append(float64Slice, val) } return float64Slice } // StringMapKeys returns a slice of string keys from a map. func StringMapKeys(stringMap map[string]interface{}) []string { stringSlice := []string{} for k := range stringMap { stringSlice = append(stringSlice, k) } return stringSlice } // GenerateRandomDisplayColor will return a random number that is used for // referencing a color value client-side. These colors are seen as // --theme-user-colors-n. func GenerateRandomDisplayColor(maxColor int) int { rangeLower := 0 rangeUpper := maxColor return rangeLower + rand.Intn(rangeUpper-rangeLower+1) //nolint:gosec } // GetHostnameFromURL will return the hostname component from a URL string. func GetHostnameFromURL(u url.URL) string { return u.Host } // GetHostnameFromURLString will return the hostname component from a URL object. func GetHostnameFromURLString(s string) string { u, err := url.Parse(s) if err != nil { return "" } return u.Host } // GetHostnameWithoutPortFromURLString will return the hostname component without the port from a URL object. func GetHostnameWithoutPortFromURLString(s string) string { u, err := url.Parse(s) if err != nil { return "" } return u.Hostname() } // GetHashtagsFromText returns all the #Hashtags from a string. func GetHashtagsFromText(text string) []string { re := regexp.MustCompile(`#[a-zA-Z0-9_]+`) return re.FindAllString(text, -1) } // ShuffleStringSlice will shuffle a slice of strings. func ShuffleStringSlice(s []string) []string { // nolint:gosec r := rand.New(rand.NewSource(time.Now().Unix())) r.Shuffle(len(s), func(i, j int) { s[i], s[j] = s[j], s[i] }) return s } // IntPercentage returns an int percentage of a number. func IntPercentage(x, total int) int { return int(float64(x) / float64(total) * 100) } // DecodeBase64Image decodes a base64 image string into a byte array, returning the extension (including dot) for the content type. func DecodeBase64Image(url string) (bytes []byte, extension string, err error) { s := strings.SplitN(url, ",", 2) if len(s) < 2 { err = errors.New("error splitting base64 image data") return nil, "", err } bytes, err = base64.StdEncoding.DecodeString(s[1]) if err != nil { return nil, "", err } splitHeader := strings.Split(s[0], ":") if len(splitHeader) < 2 { err = errors.New("error splitting base64 image header") return nil, "", err } contentType := strings.Split(splitHeader[1], ";")[0] if contentType == "image/svg+xml" { extension = ".svg" } else if contentType == "image/gif" { extension = ".gif" } else if contentType == "image/png" { extension = ".png" } else if contentType == "image/jpeg" { extension = ".jpeg" } if extension == "" { err = errors.New("missing or invalid contentType in base64 image") return nil, "", err } return bytes, extension, nil } // RoundUpToNearest rounds up to the nearest number divisible. func RoundUpToNearest(x float32, to int) int { xInt := int(math.Ceil(float64(x))) if xInt%to == 0 { return xInt } return xInt + to - xInt%to } // RoundDownToNearest rounds down to the nearest number divisible. func RoundDownToNearest(x float32, to int) int { xInt := int(math.Floor(float64(x))) if xInt%to == 0 { return xInt } return xInt - xInt%to }