Browse Source
* 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 directorypull/2438/head
13 changed files with 439 additions and 87 deletions
@ -0,0 +1,92 @@
@@ -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)) |
||||
} |
@ -0,0 +1,84 @@
@@ -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 |
||||
} |
@ -0,0 +1,183 @@
@@ -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; |
@ -0,0 +1,7 @@
@@ -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); |
||||
} |
Loading…
Reference in new issue