Allow adding custom javascript to the page. Closes #2604

This commit is contained in:
Gabe Kangas 2023-01-18 22:38:24 -08:00
parent 053a33ac47
commit 19c228eaf6
No known key found for this signature in database
GPG key ID: 4345B2060657F330
15 changed files with 211 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<StatusState>(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 (
<div className="edit-custom-css">
<Title level={3} className="section-title">
Customize your page styling with CSS
</Title>
<p className="description">
Customize the look and feel of your Owncast instance by overriding the CSS styles of various
components on the page. Refer to the{' '}
<a href="https://owncast.online/docs/website/" rel="noopener noreferrer" target="_blank">
CSS &amp; Components guide
</a>
.
</p>
<p className="description">
Please input plain CSS text, as this will be directly injected onto your page during load.
</p>
<CodeMirror
value={content}
placeholder="/* Enter custom Javascript here */"
theme={bbedit}
height="200px"
extensions={[javascript()]}
onChange={onCSSValueChange}
/>
<br />
<div className="page-content-actions">
{hasChanged && (
<Button type="primary" onClick={handleSave}>
Save
</Button>
)}
<FormStatusIndicator status={submitStatus} />
</div>
</div>
);
};

View file

@ -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 = () => {
<PushNotificationServiceWorker />
<TitleNotifier name={name} />
<Theme />
<Script strategy="afterInteractive" src="/customjavascript" />
<Layout ref={layoutRef} className={styles.layout}>
<Header name={title || name} chatAvailable={isChatAvailable} chatDisabled={chatDisabled} />
<Content />

View file

@ -43,6 +43,10 @@ module.exports = withBundleAnalyzer(
source: '/thumbnail.jpg',
destination: 'http://localhost:8080/thumbnail.jpg', // Proxy to Backend to work around CORS.
},
{
source: '/customjavascript',
destination: 'http://localhost:8080/customjavascript', // Proxy to Backend to work around CORS.
},
];
},
pageExtensions: ['tsx'],

1
web/package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"@ant-design/icons": "4.8.0",
"@codemirror/lang-css": "6.0.1",
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/lang-markdown": "6.0.5",
"@codemirror/language-data": "6.1.0",
"@fontsource/open-sans": "4.5.13",

View file

@ -15,6 +15,7 @@
"dependencies": {
"@ant-design/icons": "4.8.0",
"@codemirror/lang-css": "6.0.1",
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/lang-markdown": "6.0.5",
"@codemirror/language-data": "6.1.0",
"@fontsource/open-sans": "4.5.13",
@ -53,11 +54,11 @@
"ua-parser-js": "1.0.32",
"video.js": "7.20.3",
"xstate": "4.35.2",
"yaml": "2.2.1"
"yaml": "2.2.1",
"@next/bundle-analyzer": "^13.1.1"
},
"devDependencies": {
"@babel/core": "7.20.12",
"style-dictionary": "3.7.2",
"@mdx-js/react": "2.2.1",
"@storybook/addon-a11y": "6.5.15",
"@storybook/addon-actions": "6.5.15",
@ -73,8 +74,6 @@
"@storybook/preset-scss": "1.0.3",
"@storybook/react": "6.5.15",
"@storybook/testing-library": "0.0.13",
"storybook-addon-designs": "6.3.1",
"storybook-addon-fetch-mock": "1.0.1",
"@svgr/webpack": "6.5.1",
"@types/chart.js": "2.9.37",
"@types/classnames": "2.3.1",
@ -108,10 +107,12 @@
"sass": "1.57.1",
"sass-loader": "13.2.0",
"sb": "6.5.15",
"storybook-addon-designs": "6.3.1",
"storybook-addon-fetch-mock": "1.0.1",
"storybook-dark-mode": "2.0.5",
"storybook-preset-less": "1.1.3",
"style-dictionary": "3.7.2",
"style-loader": "3.3.1",
"typescript": "4.9.4",
"@next/bundle-analyzer": "^13.1.1"
"typescript": "4.9.4"
}
}

View file

@ -5,6 +5,7 @@ import GeneralConfig from '../../../../components/admin/config/general/GeneralCo
import AppearanceConfig from '../../../../components/admin/config/general/AppearanceConfig';
import { AdminLayout } from '../../../../components/layouts/AdminLayout';
import { EditCustomJavascript } from '../../../../components/admin/EditCustomJavascript';
export default function PublicFacingDetails() {
return (
@ -23,6 +24,11 @@ export default function PublicFacingDetails() {
key: '2',
children: <AppearanceConfig />,
},
{
label: `Custom Scripting`,
key: '3',
children: <EditCustomJavascript />,
},
]}
/>
</div>

View file

@ -29,6 +29,7 @@ export interface ConfigDirectoryFields {
export interface ConfigInstanceDetailsFields {
customStyles: string;
customJavascript: string;
extraPageContent: string;
logo: string;
name: string;

View file

@ -11,6 +11,7 @@ export const RESET_TIMEOUT = 3000;
// CONFIG API ENDPOINTS
export const API_CUSTOM_CONTENT = '/pagecontent';
export const API_CUSTOM_CSS_STYLES = '/customstyles';
export const API_CUSTOM_JAVASCRIPT = '/customjavascript';
export const API_FFMPEG = '/ffmpegpath';
export const API_INSTANCE_URL = '/serverurl';
export const API_LOGO = '/logo';

View file

@ -12,6 +12,7 @@ export const initialServerConfigState: ConfigDetails = {
adminPassword: '',
instanceDetails: {
customStyles: '',
customJavascript: '',
extraPageContent: '',
logo: '',
name: '',