mirror of
				https://github.com/owncast/owncast.git
				synced 2025-10-31 10:08:10 +08:00 
			
		
		
		
	Allow adding custom javascript to the page. Closes #2604
This commit is contained in:
		| @ -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: '', | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Gabe Kangas
					Gabe Kangas