diff --git a/config/constants.go b/config/constants.go index 3a16149cd..9bacbc093 100644 --- a/config/constants.go +++ b/config/constants.go @@ -9,8 +9,8 @@ const ( FfmpegSuggestedVersion = "v4.1.5" // Requires the v // DataDirectory is the directory we save data to. DataDirectory = "data" - // EmojiDir is relative to the static directory. - EmojiDir = "/img/emoji" + // EmojiDir defines the URL route prefix for emoji requests. + EmojiDir = "/img/emoji/" // MaxUserColor is the largest color value available to assign to users. // They start at 0 and can be treated as IDs more than colors themselves. MaxUserColor = 7 @@ -25,6 +25,6 @@ var ( // HLSStoragePath is the directory HLS video is written to. HLSStoragePath = filepath.Join(DataDirectory, "hls") - // CustomEmojiPath is the optional emoji override directory. + // CustomEmojiPath is the emoji directory. CustomEmojiPath = filepath.Join(DataDirectory, "emoji") ) diff --git a/controllers/admin/config.go b/controllers/admin/config.go index 48a37623e..84b98e78c 100644 --- a/controllers/admin/config.go +++ b/controllers/admin/config.go @@ -1,7 +1,6 @@ package admin import ( - "encoding/base64" "encoding/json" "fmt" "net" @@ -228,39 +227,12 @@ func SetLogo(w http.ResponseWriter, r *http.Request) { return } - s := strings.SplitN(configValue.Value.(string), ",", 2) - if len(s) < 2 { - controllers.WriteSimpleResponse(w, false, "Error splitting base64 image data.") - return - } - bytes, err := base64.StdEncoding.DecodeString(s[1]) + bytes, extension, err := utils.DecodeBase64Image(configValue.Value.(string)) if err != nil { controllers.WriteSimpleResponse(w, false, err.Error()) return } - splitHeader := strings.Split(s[0], ":") - if len(splitHeader) < 2 { - controllers.WriteSimpleResponse(w, false, "Error splitting base64 image header.") - return - } - contentType := strings.Split(splitHeader[1], ";")[0] - extension := "" - 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 == "" { - controllers.WriteSimpleResponse(w, false, "Missing or invalid contentType in base64 image.") - return - } - imgPath := filepath.Join("data", "logo"+extension) if err := os.WriteFile(imgPath, bytes, 0o600); err != nil { controllers.WriteSimpleResponse(w, false, err.Error()) diff --git a/controllers/admin/emoji.go b/controllers/admin/emoji.go new file mode 100644 index 000000000..de99acb85 --- /dev/null +++ b/controllers/admin/emoji.go @@ -0,0 +1,92 @@ +package admin + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/owncast/owncast/config" + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/utils" +) + +// UploadCustomEmoji allows POSTing a new custom emoji to the server. +func UploadCustomEmoji(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + type postEmoji struct { + Name string `json:"name"` + Data string `json:"data"` + } + + emoji := new(postEmoji) + + if err := json.NewDecoder(r.Body).Decode(emoji); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + bytes, _, err := utils.DecodeBase64Image(emoji.Data) + if err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + // Prevent path traversal attacks + var emojiFileName = filepath.Base(emoji.Name) + var targetPath = filepath.Join(config.CustomEmojiPath, emojiFileName) + + err = os.MkdirAll(config.CustomEmojiPath, 0700) + if err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + if utils.DoesFileExists(targetPath) { + controllers.WriteSimpleResponse(w, false, fmt.Sprintf("An emoji with the name %q already exists", emojiFileName)) + return + } + + if err = os.WriteFile(targetPath, bytes, 0o600); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, fmt.Sprintf("Emoji %q has been uploaded", emojiFileName)) +} + +// DeleteCustomEmoji deletes a custom emoji. +func DeleteCustomEmoji(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + type deleteEmoji struct { + Name string `json:"name"` + } + + emoji := new(deleteEmoji) + + if err := json.NewDecoder(r.Body).Decode(emoji); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + var emojiFileName = filepath.Base(emoji.Name) + var targetPath = filepath.Join(config.CustomEmojiPath, emojiFileName) + + if err := os.Remove(targetPath); err != nil { + if os.IsNotExist(err) { + controllers.WriteSimpleResponse(w, false, fmt.Sprintf("Emoji %q doesn't exist", emojiFileName)) + } else { + controllers.WriteSimpleResponse(w, false, err.Error()) + } + return + } + + controllers.WriteSimpleResponse(w, true, fmt.Sprintf("Emoji %q has been deleted", emojiFileName)) +} diff --git a/controllers/emoji.go b/controllers/emoji.go index bb0e9f04b..f66e3cc9a 100644 --- a/controllers/emoji.go +++ b/controllers/emoji.go @@ -2,50 +2,17 @@ package controllers import ( "encoding/json" - "io/fs" "net/http" "os" - "path/filepath" "strings" "github.com/owncast/owncast/config" - "github.com/owncast/owncast/models" - "github.com/owncast/owncast/static" - "github.com/owncast/owncast/utils" - log "github.com/sirupsen/logrus" + "github.com/owncast/owncast/core/data" ) -var useCustomEmojiDirectory = utils.DoesFileExists(config.CustomEmojiPath) - -// getCustomEmojiList returns a list of custom emoji either from the cache or from the emoji directory. -func getCustomEmojiList() []models.CustomEmoji { - var emojiFS fs.FS - if useCustomEmojiDirectory { - emojiFS = os.DirFS(config.CustomEmojiPath) - } else { - emojiFS = static.GetEmoji() - } - - emojiResponse := make([]models.CustomEmoji, 0) - - files, err := fs.Glob(emojiFS, "*") - if err != nil { - log.Errorln(err) - return emojiResponse - } - - for _, name := range files { - emojiPath := filepath.Join(config.EmojiDir, name) - singleEmoji := models.CustomEmoji{Name: name, URL: emojiPath} - emojiResponse = append(emojiResponse, singleEmoji) - } - - return emojiResponse -} - -// GetCustomEmojiList returns a list of custom emoji via the API. +// GetCustomEmojiList returns a list of emoji via the API. func GetCustomEmojiList(w http.ResponseWriter, r *http.Request) { - emojiList := getCustomEmojiList() + emojiList := data.GetEmojiList() if err := json.NewEncoder(w).Encode(emojiList); err != nil { InternalErrorHandler(w, err) @@ -57,13 +24,6 @@ func GetCustomEmojiImage(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/img/emoji/") r.URL.Path = path - var emojiStaticServer http.Handler - if useCustomEmojiDirectory { - emojiFS := os.DirFS(config.CustomEmojiPath) - emojiStaticServer = http.FileServer(http.FS(emojiFS)) - } else { - emojiStaticServer = http.FileServer(http.FS(static.GetEmoji())) - } - - emojiStaticServer.ServeHTTP(w, r) + emojiFS := os.DirFS(config.CustomEmojiPath) + http.FileServer(http.FS(emojiFS)).ServeHTTP(w, r) } diff --git a/core/data/emoji.go b/core/data/emoji.go new file mode 100644 index 000000000..1233a08f3 --- /dev/null +++ b/core/data/emoji.go @@ -0,0 +1,84 @@ +package data + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/owncast/owncast/config" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/static" + "github.com/owncast/owncast/utils" + log "github.com/sirupsen/logrus" +) + +// GetEmojiList returns a list of custom emoji from the emoji directory. +func GetEmojiList() []models.CustomEmoji { + var emojiFS = os.DirFS(config.CustomEmojiPath) + + emojiResponse := make([]models.CustomEmoji, 0) + + files, err := fs.Glob(emojiFS, "*") + if err != nil { + log.Errorln(err) + return emojiResponse + } + + for _, name := range files { + emojiPath := filepath.Join(config.EmojiDir, name) + singleEmoji := models.CustomEmoji{Name: name, URL: emojiPath} + emojiResponse = append(emojiResponse, singleEmoji) + } + + return emojiResponse +} + +// SetupEmojiDirectory sets up the custom emoji directory by copying all built-in +// emojis if the directory does not yet exist. +func SetupEmojiDirectory() (err error) { + if utils.DoesFileExists(config.CustomEmojiPath) { + return nil + } + + if err = os.MkdirAll(config.CustomEmojiPath, 0o750); err != nil { + return fmt.Errorf("unable to create custom emoji directory: %w", err) + } + + staticFS := static.GetEmoji() + files, err := fs.Glob(staticFS, "*") + if err != nil { + return fmt.Errorf("unable to read built-in emoji files: %w", err) + } + + // Now copy all built-in emojis to the custom emoji directory + for _, name := range files { + emojiPath := filepath.Join(config.CustomEmojiPath, filepath.Base(name)) + + // nolint:gosec + diskFile, err := os.Create(emojiPath) + if err != nil { + return fmt.Errorf("unable to create custom emoji file on disk: %w", err) + } + + memFile, err := staticFS.Open(name) + if err != nil { + _ = diskFile.Close() + return fmt.Errorf("unable to open built-in emoji file: %w", err) + } + + if _, err = io.Copy(diskFile, memFile); err != nil { + _ = diskFile.Close() + _ = os.Remove(emojiPath) + return fmt.Errorf("unable to copy built-in emoji file to disk: %w", err) + } + + if err = diskFile.Close(); err != nil { + _ = os.Remove(emojiPath) + return fmt.Errorf("unable to close custom emoji file on disk: %w", err) + } + } + + return nil +} diff --git a/main.go b/main.go index 2e34f5dd8..963eb853a 100644 --- a/main.go +++ b/main.go @@ -47,6 +47,10 @@ func main() { log.Fatalln("Cannot create data directory", err) } } + // Set up emoji directory + if err := data.SetupEmojiDirectory(); err != nil { + log.Fatalln("Cannot set up emoji directory", err) + } // Recreate the temp dir if utils.DoesFileExists(config.TempDir) { diff --git a/router/router.go b/router/router.go index 352f7d25f..dae1994e4 100644 --- a/router/router.go +++ b/router/router.go @@ -39,7 +39,7 @@ func Start() error { http.HandleFunc("/logo", controllers.GetLogo) // Return a single emoji image. - http.HandleFunc("/img/emoji/", controllers.GetCustomEmojiImage) + http.HandleFunc(config.EmojiDir, controllers.GetCustomEmojiImage) // return the logo @@ -156,6 +156,12 @@ func Start() error { // Set the following state of a follower or follow request. http.HandleFunc("/api/admin/followers/approve", middleware.RequireAdminAuth(admin.ApproveFollower)) + // Upload custom emoji + http.HandleFunc("/api/admin/emoji/upload", middleware.RequireAdminAuth(admin.UploadCustomEmoji)) + + // Delete custom emoji + http.HandleFunc("/api/admin/emoji/delete", middleware.RequireAdminAuth(admin.DeleteCustomEmoji)) + // Update config values // Change the current streaming key in memory diff --git a/utils/utils.go b/utils/utils.go index e2a84fcc6..df7287e5f 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -2,6 +2,7 @@ package utils import ( "bytes" + "encoding/base64" "errors" "fmt" "io" @@ -364,3 +365,42 @@ func ShuffleStringSlice(s []string) []string { 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 + } + + bytes, err = base64.StdEncoding.DecodeString(s[1]) + if err != nil { + return + } + + splitHeader := strings.Split(s[0], ":") + if len(splitHeader) < 2 { + err = errors.New("error splitting base64 image header") + return + } + + 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 + } + + return bytes, extension, nil +} diff --git a/web/components/MainLayout.tsx b/web/components/MainLayout.tsx index 3d1a01926..1ce7d6312 100644 --- a/web/components/MainLayout.tsx +++ b/web/components/MainLayout.tsx @@ -143,6 +143,10 @@ export const MainLayout: FC = ({ children }) => { label: Users, key: 'chat-users', }, + { + label: Emojis, + key: 'emojis', + }, ]; const utilitiesMenu = [ diff --git a/web/components/config/EditLogo.tsx b/web/components/config/EditLogo.tsx index 658c8d061..91cb4f94f 100644 --- a/web/components/config/EditLogo.tsx +++ b/web/components/config/EditLogo.tsx @@ -18,13 +18,7 @@ import { } from '../../utils/input-statuses'; import { NEXT_PUBLIC_API_HOST } from '../../utils/apis'; -const ACCEPTED_FILE_TYPES = ['image/png', 'image/jpeg', 'image/gif']; - -function getBase64(img: File | Blob, callback: (imageUrl: string | ArrayBuffer) => void) { - const reader = new FileReader(); - reader.addEventListener('load', () => callback(reader.result)); - reader.readAsDataURL(img); -} +import { ACCEPTED_IMAGE_TYPES, getBase64 } from '../../utils/images'; export const EditLogo: FC = () => { const [logoUrl, setlogoUrl] = useState(null); @@ -53,7 +47,7 @@ export const EditLogo: FC = () => { // eslint-disable-next-line consistent-return return new Promise((res, rej) => { - if (!ACCEPTED_FILE_TYPES.includes(file.type)) { + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { const msg = `File type is not supported: ${file.type}`; setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`)); resetTimer = setTimeout(resetStates, RESET_TIMEOUT); @@ -108,7 +102,7 @@ export const EditLogo: FC = () => { listType="picture" className="avatar-uploader" showUploadList={false} - accept={ACCEPTED_FILE_TYPES.join(',')} + accept={ACCEPTED_IMAGE_TYPES.join(',')} beforeUpload={beforeUpload} customRequest={handleLogoUpdate} disabled={loading} diff --git a/web/pages/admin/chat/emojis.tsx b/web/pages/admin/chat/emojis.tsx new file mode 100644 index 000000000..e40b7dcd6 --- /dev/null +++ b/web/pages/admin/chat/emojis.tsx @@ -0,0 +1,183 @@ +import { DeleteOutlined } from '@ant-design/icons'; +import { Button, Space, Table, Typography, Upload } from 'antd'; +import { RcFile } from 'antd/lib/upload'; +import React, { useEffect, useState } from 'react'; +import FormStatusIndicator from '../../../components/config/FormStatusIndicator'; + +import { DELETE_EMOJI, fetchData, UPLOAD_EMOJI } from '../../../utils/apis'; + +import { ACCEPTED_IMAGE_TYPES, getBase64 } from '../../../utils/images'; +import { + createInputStatus, + STATUS_ERROR, + STATUS_PROCESSING, + STATUS_SUCCESS, +} from '../../../utils/input-statuses'; +import { RESET_TIMEOUT } from '../../../utils/config-constants'; +import { URL_CUSTOM_EMOJIS } from '../../../utils/constants'; + +type CustomEmoji = { + name: string; + url: string; +}; + +const { Title, Paragraph } = Typography; + +const Emoji = () => { + const [emojis, setEmojis] = useState([]); + + const [loading, setLoading] = useState(false); + const [submitStatus, setSubmitStatus] = useState(null); + const [uploadFile, setUploadFile] = useState(null); + + let resetTimer = null; + const resetStates = () => { + setSubmitStatus(null); + clearTimeout(resetTimer); + resetTimer = null; + }; + + async function getEmojis() { + setLoading(true); + try { + const response = await fetchData(URL_CUSTOM_EMOJIS); + setEmojis(response); + } catch (error) { + console.error('error fetching emojis', error); + } + setLoading(false); + } + useEffect(() => { + getEmojis(); + }, []); + + async function handleDelete(name: string) { + setLoading(true); + + setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Deleting emoji...')); + + try { + const response = await fetchData(DELETE_EMOJI, { + method: 'POST', + data: { name }, + }); + + if (response instanceof Error) { + throw response; + } + + setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Emoji deleted')); + resetTimer = setTimeout(resetStates, RESET_TIMEOUT); + } catch (error) { + setSubmitStatus(createInputStatus(STATUS_ERROR, `${error}`)); + setLoading(false); + resetTimer = setTimeout(resetStates, RESET_TIMEOUT); + } + + getEmojis(); + } + + async function handleEmojiUpload() { + setLoading(true); + try { + setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Converting emoji...')); + + // eslint-disable-next-line consistent-return + const emojiData = await new Promise((res, rej) => { + if (!ACCEPTED_IMAGE_TYPES.includes(uploadFile.type)) { + const msg = `File type is not supported: ${uploadFile.type}`; + // eslint-disable-next-line no-promise-executor-return + return rej(msg); + } + + getBase64(uploadFile, (url: string) => + res({ + name: uploadFile.name, + url, + }), + ); + }); + + setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Uploading emoji...')); + + const response = await fetchData(UPLOAD_EMOJI, { + method: 'POST', + data: { + name: emojiData.name, + data: emojiData.url, + }, + }); + + if (response instanceof Error) { + throw response; + } + + setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Emoji uploaded successfully!')); + getEmojis(); + } catch (error) { + setSubmitStatus(createInputStatus(STATUS_ERROR, `${error}`)); + } + + resetTimer = setTimeout(resetStates, RESET_TIMEOUT); + setLoading(false); + } + + const columns = [ + { + title: '', + key: 'delete', + render: (text, record) => ( + +