diff --git a/docs/docs.go b/docs/docs.go index 4e48c88d0..985d185f8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -11864,6 +11864,10 @@ const docTemplate = `{ "schema.SiteCustomCssHTMLReq": { "type": "object", "properties": { + "custom_banner": { + "type": "string", + "maxLength": 65536 + }, "custom_css": { "type": "string", "maxLength": 65536 @@ -11889,6 +11893,10 @@ const docTemplate = `{ "schema.SiteCustomCssHTMLResp": { "type": "object", "properties": { + "custom_banner": { + "type": "string", + "maxLength": 65536 + }, "custom_css": { "type": "string", "maxLength": 65536 diff --git a/docs/swagger.json b/docs/swagger.json index a075dfe45..18eb54fdc 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -11837,6 +11837,10 @@ "schema.SiteCustomCssHTMLReq": { "type": "object", "properties": { + "custom_banner": { + "type": "string", + "maxLength": 65536 + }, "custom_css": { "type": "string", "maxLength": 65536 @@ -11862,6 +11866,10 @@ "schema.SiteCustomCssHTMLResp": { "type": "object", "properties": { + "custom_banner": { + "type": "string", + "maxLength": 65536 + }, "custom_css": { "type": "string", "maxLength": 65536 diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b3416a10e..2ce82e1f0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2353,6 +2353,9 @@ definitions: type: object schema.SiteCustomCssHTMLReq: properties: + custom_banner: + maxLength: 65536 + type: string custom_css: maxLength: 65536 type: string @@ -2371,6 +2374,9 @@ definitions: type: object schema.SiteCustomCssHTMLResp: properties: + custom_banner: + maxLength: 65536 + type: string custom_css: maxLength: 65536 type: string diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5d1faa3e0..c254c2222 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -2225,6 +2225,9 @@ ui: header: label: Header text: This will insert after <body> + banner: + label: Banner + text: This will insert below the site header. footer: label: Footer text: This will insert before </body>. diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index 1d0b27ff6..cb8493bd8 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -239,6 +239,7 @@ type SiteCustomCssHTMLReq struct { CustomHead string `validate:"omitempty,gt=0,lte=65536" json:"custom_head"` CustomCss string `validate:"omitempty,gt=0,lte=65536" json:"custom_css"` CustomHeader string `validate:"omitempty,gt=0,lte=65536" json:"custom_header"` + CustomBanner string `validate:"omitempty,gt=0,lte=65536" json:"custom_banner"` CustomFooter string `validate:"omitempty,gt=0,lte=65536" json:"custom_footer"` CustomSideBar string `validate:"omitempty,gt=0,lte=65536" json:"custom_sidebar"` } diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 8ab714230..7f5ac9670 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -479,6 +479,7 @@ export interface AdminSettingsCustom { custom_css: string; custom_head: string; custom_header: string; + custom_banner: string; custom_footer: string; custom_sidebar: string; } diff --git a/ui/src/components/CustomBanner/index.tsx b/ui/src/components/CustomBanner/index.tsx new file mode 100644 index 000000000..2d6040991 --- /dev/null +++ b/ui/src/components/CustomBanner/index.tsx @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { memo, useEffect, useRef } from 'react'; + +import { customizeStore } from '@/stores'; + +/** + * Re-create every script node in the container, because the browser runs a + * script only when it is inserted as a node. dangerouslySetInnerHTML parses + * script tags without running them. + */ +const activateScriptNodes = (el: HTMLElement) => { + el.querySelectorAll('script').forEach((so) => { + const script = document.createElement('script'); + // Wrap a classic script, to keep its declarations out of the page scope. + // Copy every other script as it is: a module has its own scope, and a data + // block such as application/ld+json must keep its content. The test matches + // the types the browser runs. + const type = so.getAttribute('type')?.trim().toLowerCase(); + const isClassic = + !type || /^(text|application)\/(x-)?(java|ecma)script$/.test(type); + // The line breaks matter: a script that ends in a line comment would + // otherwise swallow the closing brace. + script.text = isClassic ? `(() => {\n${so.text}\n})();` : so.text; + for (let i = 0; i < so.attributes.length; i += 1) { + const attr = so.attributes[i]; + script.setAttribute(attr.name, attr.value); + } + so.parentNode?.replaceChild(script, so); + }); +}; + +const Index = () => { + const customBanner = customizeStore((state) => state.custom_banner); + const slotRef = useRef(null); + // The content whose scripts already ran. It stops the scripts from running + // again when React re-runs this effect for the same content. + const activatedBanner = useRef(null); + + useEffect(() => { + if (!customBanner) { + activatedBanner.current = null; + return; + } + if (!slotRef.current || customBanner === activatedBanner.current) { + return; + } + activatedBanner.current = customBanner; + activateScriptNodes(slotRef.current); + }, [customBanner]); + + if (!customBanner) { + return null; + } + + return ( +
+ ); +}; + +export default memo(Index); diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index 5c81739bb..ef510d672 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -56,6 +56,7 @@ import QuestionList from './QuestionList'; import HotQuestions from './HotQuestions'; import HttpErrorContent from './HttpErrorContent'; import CustomSidebar from './CustomSidebar'; +import CustomBanner from './CustomBanner'; import ImgViewer from './ImgViewer'; import SideNav from './SideNav'; import PluginRender from './PluginRender'; @@ -111,6 +112,7 @@ export { HotQuestions, HttpErrorContent, CustomSidebar, + CustomBanner, ImgViewer, SideNav, PluginRender, diff --git a/ui/src/pages/Admin/CssAndHtml/index.tsx b/ui/src/pages/Admin/CssAndHtml/index.tsx index 557ffa3c6..d59bc5dd4 100644 --- a/ui/src/pages/Admin/CssAndHtml/index.tsx +++ b/ui/src/pages/Admin/CssAndHtml/index.tsx @@ -50,6 +50,11 @@ const Index: FC = () => { title: t('header.label'), description: t('header.text'), }, + custom_banner: { + type: 'string', + title: t('banner.label'), + description: t('banner.text'), + }, custom_sidebar: { type: 'string', title: t('sidebar.label'), @@ -84,6 +89,13 @@ const Index: FC = () => { className: ['small', 'font-monospace'], }, }, + custom_banner: { + 'ui:widget': 'textarea', + 'ui:options': { + rows: 10, + className: ['small', 'font-monospace'], + }, + }, custom_sidebar: { 'ui:widget': 'textarea', 'ui:options': { @@ -108,6 +120,7 @@ const Index: FC = () => { custom_css: formData.custom_css.value, custom_head: formData.custom_head.value, custom_header: formData.custom_header.value, + custom_banner: formData.custom_banner.value, custom_sidebar: formData.custom_sidebar.value, custom_footer: formData.custom_footer.value, }; @@ -137,6 +150,7 @@ const Index: FC = () => { formMeta.custom_css.value = setting.custom_css; formMeta.custom_head.value = setting.custom_head; formMeta.custom_header.value = setting.custom_header; + formMeta.custom_banner.value = setting.custom_banner; formMeta.custom_sidebar.value = setting.custom_sidebar; formMeta.custom_footer.value = setting.custom_footer; setFormData(formMeta); diff --git a/ui/src/pages/Layout/index.tsx b/ui/src/pages/Layout/index.tsx index 048ca812e..9aaae204f 100644 --- a/ui/src/pages/Layout/index.tsx +++ b/ui/src/pages/Layout/index.tsx @@ -33,6 +33,7 @@ import { } from '@/stores'; import { Header, + CustomBanner, Toast, Customize, CustomizeTheme, @@ -211,6 +212,7 @@ const Layout: FC = () => { revalidateOnFocus: false, }}>
+
void; @@ -38,6 +40,7 @@ const loginSetting = create((set) => ({ custom_css: '', custom_head: '', custom_header: '', + custom_banner: '', custom_footer: '', custom_sidebar: '', update: (params) =>