Feature: emoji editor (#2411)

* Custom emoji editor: implement backend

This reuses the logo upload code

* Implement emoji edit admin interface

Again reuse base64 logic from the logo upload

* Allow toggling between uploaded and default emojis

* Add route that always serves uploaded emojis

This is needed for the admin emoji interface,
as otherwise the emojis will 404 if custom emojis are disabled

* Fix linter warnings

* Remove custom/uploaded emoji logic

* Reset timer after emoji deletion

* Setup: copy built-in emojis to emoji directory
This commit is contained in:
Philipp 2022-12-12 17:40:43 +01:00 committed by GitHub
parent 592425bfc9
commit dc54dfe363
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 439 additions and 87 deletions

View file

@ -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")
)

View file

@ -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())

View file

@ -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))
}

View file

@ -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)
}

84
core/data/emoji.go Normal file
View file

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

View file

@ -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) {

View file

@ -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

View file

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

View file

@ -143,6 +143,10 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
label: <Link href="/admin/chat/users">Users</Link>,
key: 'chat-users',
},
{
label: <Link href="/admin/chat/emojis">Emojis</Link>,
key: 'emojis',
},
];
const utilitiesMenu = [

View file

@ -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<void>((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}

View file

@ -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<CustomEmoji[]>([]);
const [loading, setLoading] = useState(false);
const [submitStatus, setSubmitStatus] = useState(null);
const [uploadFile, setUploadFile] = useState<RcFile>(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<CustomEmoji>((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) => (
<Space size="middle">
<Button onClick={() => handleDelete(record.name)} icon={<DeleteOutlined />} />
</Space>
),
},
{
title: 'Name',
key: 'name',
dataIndex: 'name',
},
{
title: 'Emoji',
key: 'url',
render: (text, record) => (
<img src={record.url} alt={record.name} style={{ maxWidth: '2vw' }} />
),
},
];
return (
<div>
<Title>Emojis</Title>
<Paragraph>
Here you can upload new custom emojis for usage in the chat. When uploading a new emoji, the
filename will be used as emoji name.
</Paragraph>
<Table
rowKey={record => record.url}
dataSource={emojis}
columns={columns}
pagination={false}
/>
<br />
<Upload
name="emoji"
listType="picture"
className="emoji-uploader"
showUploadList={false}
accept={ACCEPTED_IMAGE_TYPES.join(',')}
beforeUpload={setUploadFile}
customRequest={handleEmojiUpload}
disabled={loading}
>
<Button type="primary" disabled={loading}>
Upload new emoji
</Button>
</Upload>
<FormStatusIndicator status={submitStatus} />
</div>
);
};
export default Emoji;

View file

@ -64,6 +64,12 @@ export const CHAT_HISTORY = `${API_LOCATION}chat/messages`;
// Get chat history
export const UPDATE_CHAT_MESSGAE_VIZ = `/api/admin/chat/messagevisibility`;
// Upload a new custom emoji
export const UPLOAD_EMOJI = `${API_LOCATION}emoji/upload`;
// Delete a custom emoji
export const DELETE_EMOJI = `${API_LOCATION}emoji/delete`;
// Get all access tokens
export const ACCESS_TOKENS = `${API_LOCATION}accesstokens`;

7
web/utils/images.ts Normal file
View file

@ -0,0 +1,7 @@
export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
export function getBase64(img: File | Blob, callback: (imageUrl: string | ArrayBuffer) => void) {
const reader = new FileReader();
reader.addEventListener('load', () => callback(reader.result));
reader.readAsDataURL(img);
}