owncast/utils/utils.go

463 lines
12 KiB
Go

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
}
// 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
}