Allow adding custom javascript to the page. Closes #2604
This commit is contained in:
parent
053a33ac47
commit
19c228eaf6
@ -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 {
|
||||
|
@ -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"`
|
||||
}
|
||||
|
||||
|
13
controllers/customJavascript.go
Normal file
13
controllers/customJavascript.go
Normal 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))
|
||||
}
|
@ -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)
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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);
|
||||
|
118
web/components/admin/EditCustomJavascript.tsx
Normal file
118
web/components/admin/EditCustomJavascript.tsx
Normal 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 & 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>
|
||||
);
|
||||
};
|
@ -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 />
|
||||
|
@ -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
1
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -29,6 +29,7 @@ export interface ConfigDirectoryFields {
|
||||
|
||||
export interface ConfigInstanceDetailsFields {
|
||||
customStyles: string;
|
||||
customJavascript: string;
|
||||
extraPageContent: string;
|
||||
logo: string;
|
||||
name: string;
|
||||
|
@ -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';
|
||||
|
@ -12,6 +12,7 @@ export const initialServerConfigState: ConfigDetails = {
|
||||
adminPassword: '',
|
||||
instanceDetails: {
|
||||
customStyles: '',
|
||||
customJavascript: '',
|
||||
extraPageContent: '',
|
||||
logo: '',
|
||||
name: '',
|
||||
|
Loading…
Reference in New Issue
Block a user