diff --git a/controllers/admin/config.go b/controllers/admin/config.go index 78077a238..9e0335e11 100644 --- a/controllers/admin/config.go +++ b/controllers/admin/config.go @@ -650,6 +650,22 @@ func SetCustomStyles(w http.ResponseWriter, r *http.Request) { controllers.WriteSimpleResponse(w, true, "custom styles updated") } +// SetCustomJavascript will set the Javascript string we insert into the page. +func SetCustomJavascript(w http.ResponseWriter, r *http.Request) { + customJavascript, success := getValueFromRequest(w, r) + if !success { + controllers.WriteSimpleResponse(w, false, "unable to update custom javascript") + return + } + + if err := data.SetCustomJavascript(customJavascript.Value.(string)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "custom styles updated") +} + // SetForbiddenUsernameList will set the list of usernames we do not allow to use. func SetForbiddenUsernameList(w http.ResponseWriter, r *http.Request) { type forbiddenUsernameListRequest struct { diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index a86bd5a46..7f26a411f 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -46,6 +46,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { SocialHandles: data.GetSocialHandles(), NSFW: data.GetNSFW(), CustomStyles: data.GetCustomStyles(), + CustomJavascript: data.GetCustomJavascript(), AppearanceVariables: data.GetCustomColorVariableValues(), }, FFmpegPath: ffmpeg, @@ -138,6 +139,7 @@ type webConfigResponse struct { StreamTitle string `json:"streamTitle"` // What's going on with the current stream SocialHandles []models.SocialHandle `json:"socialHandles"` CustomStyles string `json:"customStyles"` + CustomJavascript string `json:"customJavascript"` AppearanceVariables map[string]string `json:"appearanceVariables"` } diff --git a/controllers/customJavascript.go b/controllers/customJavascript.go new file mode 100644 index 000000000..cdacfe76a --- /dev/null +++ b/controllers/customJavascript.go @@ -0,0 +1,13 @@ +package controllers + +import ( + "net/http" + + "github.com/owncast/owncast/core/data" +) + +// ServeCustomJavascript will serve optional custom Javascript. +func ServeCustomJavascript(w http.ResponseWriter, r *http.Request) { + js := data.GetCustomJavascript() + w.Write([]byte(js)) +} diff --git a/core/data/config.go b/core/data/config.go index fd4957584..48e8feb5b 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -44,6 +44,7 @@ const ( chatDisabledKey = "chat_disabled" externalActionsKey = "external_actions" customStylesKey = "custom_styles" + customJavascriptKey = "custom_javascript" videoCodecKey = "video_codec" blockedUsernamesKey = "blocked_usernames" publicKeyKey = "public_key" @@ -560,6 +561,21 @@ func GetCustomStyles() string { return style } +// SetCustomJavascript will save a string with Javascript to insert into the page. +func SetCustomJavascript(styles string) error { + return _datastore.SetString(customJavascriptKey, styles) +} + +// GetCustomJavascript will return a string with Javascript to insert into the page. +func GetCustomJavascript() string { + style, err := _datastore.GetString(customJavascriptKey) + if err != nil { + return "" + } + + return style +} + // SetVideoCodec will set the codec used for video encoding. func SetVideoCodec(codec string) error { return _datastore.SetString(videoCodecKey, codec) diff --git a/router/router.go b/router/router.go index 559448575..7dd86a4ca 100644 --- a/router/router.go +++ b/router/router.go @@ -39,6 +39,9 @@ func Start() error { http.HandleFunc("/preview.gif", controllers.GetPreview) http.HandleFunc("/logo", controllers.GetLogo) + // Custom Javascript + http.HandleFunc("/customjavascript", controllers.ServeCustomJavascript) + // Return a single emoji image. http.HandleFunc(config.EmojiDir, controllers.GetCustomEmojiImage) @@ -315,6 +318,9 @@ func Start() error { // set custom style css http.HandleFunc("/api/admin/config/customstyles", middleware.RequireAdminAuth(admin.SetCustomStyles)) + // set custom style javascript + http.HandleFunc("/api/admin/config/customjavascript", middleware.RequireAdminAuth(admin.SetCustomJavascript)) + // Video playback metrics http.HandleFunc("/api/admin/metrics/video", middleware.RequireAdminAuth(admin.GetVideoPlaybackMetrics)) diff --git a/test/automated/api/configmanagement.test.js b/test/automated/api/configmanagement.test.js index c2481082c..82f55fda4 100644 --- a/test/automated/api/configmanagement.test.js +++ b/test/automated/api/configmanagement.test.js @@ -132,6 +132,8 @@ const newFederationConfig = { const newHideViewerCount = !defaultHideViewerCount; const overriddenWebsocketHost = 'ws://lolcalhost.biz'; +const customCSS = randomString(); +const customJavascript = randomString(); test('verify default config values', async (done) => { const res = await request.get('/api/config'); @@ -315,6 +317,16 @@ test('set custom style values', async (done) => { done(); }); +test('set custom css', async (done) => { + await sendAdminRequest('config/customstyles', customCSS); + done(); +}); + +test('set custom javascript', async (done) => { + await sendAdminRequest('config/customjavascript', customJavascript); + done(); +}); + test('enable directory', async (done) => { const res = await sendAdminRequest('config/directoryenabled', true); done(); @@ -367,6 +379,7 @@ test('verify updated config values', async (done) => { expect(res.body.logo).toBe('/logo'); expect(res.body.socialHandles).toStrictEqual(newSocialHandles); expect(res.body.socketHostOverride).toBe(overriddenWebsocketHost); + expect(res.body.customStyles).toBe(customCSS); done(); }); @@ -393,6 +406,9 @@ test('verify updated admin configuration', async (done) => { expect(res.body.instanceDetails.socialHandles).toStrictEqual( newSocialHandles ); + expect(res.body.instanceDetails.customStyles).toBe(customCSS); + expect(res.body.instanceDetails.customJavascript).toBe(customJavascript); + expect(res.body.forbiddenUsernames).toStrictEqual(newForbiddenUsernames); expect(res.body.streamKeys).toStrictEqual(newStreamKeys); expect(res.body.socketHostOverride).toBe(overriddenWebsocketHost); diff --git a/web/components/admin/EditCustomJavascript.tsx b/web/components/admin/EditCustomJavascript.tsx new file mode 100644 index 000000000..1e85233e3 --- /dev/null +++ b/web/components/admin/EditCustomJavascript.tsx @@ -0,0 +1,118 @@ +import React, { useState, useEffect, useContext, FC } from 'react'; +import { Typography, Button } from 'antd'; +import CodeMirror from '@uiw/react-codemirror'; +import { bbedit } from '@uiw/codemirror-theme-bbedit'; +import { javascript } from '@codemirror/lang-javascript'; + +import { ServerStatusContext } from '../../utils/server-status-context'; +import { + postConfigUpdateToAPI, + RESET_TIMEOUT, + API_CUSTOM_JAVASCRIPT, +} from '../../utils/config-constants'; +import { + createInputStatus, + StatusState, + STATUS_ERROR, + STATUS_PROCESSING, + STATUS_SUCCESS, +} from '../../utils/input-statuses'; +import { FormStatusIndicator } from './FormStatusIndicator'; + +const { Title } = Typography; + +// eslint-disable-next-line import/prefer-default-export +export const EditCustomJavascript: FC = () => { + const [content, setContent] = useState('/* Enter custom Javascript here */'); + const [submitStatus, setSubmitStatus] = useState(null); + const [hasChanged, setHasChanged] = useState(false); + + const serverStatusData = useContext(ServerStatusContext); + const { serverConfig, setFieldInConfigState } = serverStatusData || {}; + + const { instanceDetails } = serverConfig; + const { customJavascript: initialContent } = instanceDetails; + + let resetTimer = null; + + // Clear out any validation states and messaging + const resetStates = () => { + setSubmitStatus(null); + setHasChanged(false); + clearTimeout(resetTimer); + resetTimer = null; + }; + + // posts all the tags at once as an array obj + async function handleSave() { + setSubmitStatus(createInputStatus(STATUS_PROCESSING)); + await postConfigUpdateToAPI({ + apiPath: API_CUSTOM_JAVASCRIPT, + data: { value: content }, + onSuccess: (message: string) => { + setFieldInConfigState({ + fieldName: 'customJavascript', + value: content, + path: 'instanceDetails', + }); + setSubmitStatus(createInputStatus(STATUS_SUCCESS, message)); + }, + onError: (message: string) => { + setSubmitStatus(createInputStatus(STATUS_ERROR, message)); + }, + }); + resetTimer = setTimeout(resetStates, RESET_TIMEOUT); + } + + useEffect(() => { + setContent(initialContent); + }, [instanceDetails]); + + const onCSSValueChange = React.useCallback(value => { + setContent(value); + if (value !== initialContent && !hasChanged) { + setHasChanged(true); + } else if (value === initialContent && hasChanged) { + setHasChanged(false); + } + }, []); + + return ( +
+ + Customize your page styling with CSS + + +

+ Customize the look and feel of your Owncast instance by overriding the CSS styles of various + components on the page. Refer to the{' '} + + CSS & Components guide + + . +

+

+ Please input plain CSS text, as this will be directly injected onto your page during load. +

+ + + +
+
+ {hasChanged && ( + + )} + +
+
+ ); +}; diff --git a/web/components/layouts/Main/Main.tsx b/web/components/layouts/Main/Main.tsx index c70169be4..1aa380d05 100644 --- a/web/components/layouts/Main/Main.tsx +++ b/web/components/layouts/Main/Main.tsx @@ -5,6 +5,7 @@ import Head from 'next/head'; import { FC, useEffect, useRef } from 'react'; import { Layout } from 'antd'; import dynamic from 'next/dynamic'; +import Script from 'next/script'; import { ClientConfigStore, isChatAvailableSelector, @@ -133,6 +134,8 @@ export const Main: FC = () => { +