This is an automated email from the ASF dual-hosted git repository. shuai pushed a commit to branch external-img in repository https://gitbox.apache.org/repos/asf/answer.git
commit 85903cf28c6dd729492efaec987aae98b59cfcb1 Author: shuai <[email protected]> AuthorDate: Thu Mar 13 17:40:40 2025 +0800 feat: Ask whether to show external resources before loading them --- i18n/en_US.yaml | 9 ++ i18n/zh_CN.yaml | 9 ++ ui/src/common/constants.ts | 1 + ui/src/common/interface.ts | 2 + ui/src/hooks/index.ts | 2 + ui/src/hooks/useExternalToast/index.tsx | 137 +++++++++++++++++++++ ui/src/pages/Admin/Legal/index.tsx | 29 +++++ .../pages/Install/components/FourthStep/index.tsx | 32 ++++- ui/src/pages/Install/index.tsx | 7 ++ ui/src/pages/Layout/index.tsx | 58 ++++++++- ui/src/stores/index.ts | 2 + ui/src/stores/siteLegal.ts | 38 ++++++ ui/src/utils/guard.ts | 4 + 13 files changed, 327 insertions(+), 3 deletions(-) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index c72be7b9..7311010a 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -1512,6 +1512,9 @@ ui: view: View card: Card compact: Compact + display_below: Display below + always_display: Always display + or: or search: title: Search Results keywords: Keywords @@ -2057,6 +2060,11 @@ ui: privacy_policy: label: Privacy policy text: "You can add privacy policy content here. If you already have a document hosted elsewhere, provide the full URL here." + external_content_display: + label: External content + text: "Content includes images, videos, and media embedded from external websites." + always_display: Always display external content + ask_before_display: Ask before displaying external content write: page_title: Write restrict_answer: @@ -2315,5 +2323,6 @@ ui: answers_deleted: These answers have been deleted. copy: Copy to clipboard copied: Copied + external_content_warning: External images/media are not displayed. diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index 458231a5..50ca7eb7 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -1480,6 +1480,9 @@ ui: view: 视图 card: 卡片模式 compact: 简洁模式 + display_below: 展示当前 + always_display: 一直展示 + or: 或者 search: title: 搜索结果 keywords: 关键词 @@ -2011,6 +2014,11 @@ ui: privacy_policy: label: 隐私政策 text: "您可以在此添加隐私政策内容。如果您已经在别处托管了文档,请在这里提供完整的URL。" + external_content_display: + label: 外部内容 + text: "内容包括外部网站嵌入的图片、视频和媒体。" + always_display: 始终显示外部内容 + ask_before_display: 在显示外部内容前进行询问 write: page_title: 编辑 restrict_answer: @@ -2271,4 +2279,5 @@ ui: answers_deleted: 这些回答已被删除。 copy: 复制 copied: 已复制 + external_content_warning: 不显示外部图像/媒体。 diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index d8b17db7..4c2a0168 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -31,6 +31,7 @@ export const DEFAULT_THEME = 'system'; export const ADMIN_PRIVILEGE_CUSTOM_LEVEL = 99; export const SKELETON_SHOW_TIME = 1000; export const LIST_VIEW_STORAGE_KEY = '_a_list_view_'; +export const EXTERNAL_CONTENT_DISPLAY_MODE = '_a_ecd_'; export const USER_AGENT_NAMES = { SegmentFault: 'SegmentFault', diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 29967e0f..3935c439 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -419,6 +419,7 @@ export interface SiteSettings { site_write: AdminSettingsWrite; version: string; revision: string; + site_legal: AdminSettingsLegal; } export interface AdminSettingBranding { @@ -429,6 +430,7 @@ export interface AdminSettingBranding { } export interface AdminSettingsLegal { + external_content_display: string; privacy_policy_original_text?: string; privacy_policy_parsed_text?: string; terms_of_service_original_text?: string; diff --git a/ui/src/hooks/index.ts b/ui/src/hooks/index.ts index 9abcade0..a8106061 100644 --- a/ui/src/hooks/index.ts +++ b/ui/src/hooks/index.ts @@ -31,6 +31,7 @@ import usePromptWithUnload from './usePrompt'; import useActivationEmailModal from './useActivationEmailModal'; import useCaptchaModal from './useCaptchaModal'; import useSkeletonControl from './useSkeletonControl'; +import useExternalToast from './useExternalToast'; export { useTagModal, @@ -47,4 +48,5 @@ export { useActivationEmailModal, useCaptchaModal, useSkeletonControl, + useExternalToast, }; diff --git a/ui/src/hooks/useExternalToast/index.tsx b/ui/src/hooks/useExternalToast/index.tsx new file mode 100644 index 00000000..9bbc5883 --- /dev/null +++ b/ui/src/hooks/useExternalToast/index.tsx @@ -0,0 +1,137 @@ +/* + * 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 { useLayoutEffect, useState } from 'react'; +import { Toast, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import ReactDOM from 'react-dom/client'; + +import { EXTERNAL_CONTENT_DISPLAY_MODE } from '@/common/constants'; +import { Storage } from '@/utils'; + +const toastPortal = document.createElement('div'); +toastPortal.style.position = 'fixed'; +toastPortal.style.top = '90px'; +toastPortal.style.left = '50%'; +toastPortal.style.transform = 'translate(-50%, 0)'; +toastPortal.style.maxWidth = '100%'; +toastPortal.style.zIndex = '1001'; + +const setPortalPosition = () => { + const header = document.querySelector('#header'); + if (header) { + toastPortal.style.top = `${header.getBoundingClientRect().top + 90}px`; + } +}; +const startHandlePortalPosition = () => { + setPortalPosition(); + window.addEventListener('scroll', setPortalPosition); +}; + +const stopHandlePortalPosition = () => { + setPortalPosition(); + window.removeEventListener('scroll', setPortalPosition); +}; + +const root = ReactDOM.createRoot(toastPortal); + +const useExternalToast = () => { + const [show, setShow] = useState(false); + const { t } = useTranslation('translation', { keyPrefix: 'messages' }); + + const onClose = () => { + const parent = document.querySelector('.page-wrap'); + if (parent?.contains(toastPortal)) { + parent.removeChild(toastPortal); + } + stopHandlePortalPosition(); + setShow(false); + }; + + const onShow = () => { + startHandlePortalPosition(); + setShow(true); + }; + + const showExternalResourceMode = (mode) => { + if (mode === 'always') { + Storage.set(EXTERNAL_CONTENT_DISPLAY_MODE, 'always'); + } else { + Storage.remove(EXTERNAL_CONTENT_DISPLAY_MODE); + } + const img = document.querySelectorAll('img'); + img.forEach((i) => { + if (!i.src && i.dataset.src) { + i.src = i.dataset.src; + i.removeAttribute('data-src'); + i.classList.remove('broken'); + } + }); + onClose(); + }; + + useLayoutEffect(() => { + const parent = document.querySelector('.page-wrap'); + parent?.appendChild(toastPortal); + + root.render( + <div className="d-flex justify-content-center"> + <Toast + className="align-items-center border-0" + bg="warning" + show={show} + onClose={onClose}> + <div className="d-flex"> + <Toast.Body> + {t('external_content_warning')} + <div className="d-flex align-items-center"> + <Button + variant="link" + onClick={() => showExternalResourceMode('below')} + className="btn-no-border small link-dark p-0 fw-bold"> + {t('display_below', { keyPrefix: 'btns' })} + </Button> + <span className="mx-1">{t('or', { keyPrefix: 'btns' })}</span> + <Button + variant="link" + onClick={() => showExternalResourceMode('always')} + className="btn-no-border small link-dark p-0 fw-bold"> + {t('always_display', { keyPrefix: 'btns' })} + </Button> + </div> + </Toast.Body> + <button + className="btn-close me-2 m-auto" + onClick={onClose} + data-bs-dismiss="toast" + aria-label="Close" + /> + </div> + </Toast> + </div>, + ); + }, [show]); + + return { + onShow, + }; +}; + +export default useExternalToast; diff --git a/ui/src/pages/Admin/Legal/index.tsx b/ui/src/pages/Admin/Legal/index.tsx index 83561566..4a4e4ea1 100644 --- a/ui/src/pages/Admin/Legal/index.tsx +++ b/ui/src/pages/Admin/Legal/index.tsx @@ -27,6 +27,7 @@ import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components'; import { useToast } from '@/hooks'; import { getLegalSetting, putLegalSetting } from '@/services'; import { handleFormError, scrollToElementTop } from '@/utils'; +import { siteLealStore } from '@/stores'; const Legal: FC = () => { const { t } = useTranslation('translation', { @@ -34,10 +35,29 @@ const Legal: FC = () => { }); const Toast = useToast(); + const externalContent = [ + { + value: 'always_display', + label: t('external_content_display.always_display'), + }, + { + value: 'ask_before_display', + label: t('external_content_display.ask_before_display'), + }, + ]; + const schema: JSONSchema = { title: t('page_title'), required: ['terms_of_service', 'privacy_policy'], properties: { + external_content_display: { + type: 'string', + title: t('external_content_display.label'), + description: t('external_content_display.text'), + enum: externalContent?.map((lang) => lang.value), + enumNames: externalContent?.map((lang) => lang.label), + default: 0, + }, terms_of_service: { type: 'string', title: t('terms_of_service.label'), @@ -51,6 +71,9 @@ const Legal: FC = () => { }, }; const uiSchema: UISchema = { + external_content_display: { + 'ui:widget': 'select', + }, terms_of_service: { 'ui:widget': 'textarea', 'ui:options': { @@ -71,6 +94,7 @@ const Legal: FC = () => { evt.stopPropagation(); const reqParams: Type.AdminSettingsLegal = { + external_content_display: formData.external_content_display.value, terms_of_service_original_text: formData.terms_of_service.value, terms_of_service_parsed_text: marked.parse( formData.terms_of_service.value, @@ -85,6 +109,9 @@ const Legal: FC = () => { msg: t('update', { keyPrefix: 'toast' }), variant: 'success', }); + siteLealStore.getState().update({ + external_content_display: reqParams.external_content_display, + }); }) .catch((err) => { if (err.isError) { @@ -100,6 +127,8 @@ const Legal: FC = () => { getLegalSetting().then((setting) => { if (setting) { const formMeta = { ...formData }; + formMeta.external_content_display.value = + setting.external_content_display; formMeta.terms_of_service.value = setting.terms_of_service_original_text; formMeta.privacy_policy.value = setting.privacy_policy_original_text; diff --git a/ui/src/pages/Install/components/FourthStep/index.tsx b/ui/src/pages/Install/components/FourthStep/index.tsx index 2c059851..e98c8e87 100644 --- a/ui/src/pages/Install/components/FourthStep/index.tsx +++ b/ui/src/pages/Install/components/FourthStep/index.tsx @@ -267,7 +267,7 @@ const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => { {data.contact_email.errorMsg} </Form.Control.Feedback> </Form.Group> - + <h5>{t('login_required.label')}</h5> <Form.Group controlId="login_required" className="mb-3"> <Form.Label>{t('login_required.label')}</Form.Label> <Form.Check @@ -287,6 +287,36 @@ const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => { /> <Form.Text>{t('login_required.text')}</Form.Text> </Form.Group> + <Form.Group controlId="external_content_display" className="mb-3"> + <Form.Label> + {t('external_content_display.label', { keyPrefix: 'admin.legal' })} + </Form.Label> + <Form.Select + value={data.external_content_display.value} + onChange={(e) => { + changeCallback({ + external_content_display: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }}> + <option value="always_display"> + {t('external_content_display.always_display', { + keyPrefix: 'admin.legal', + })} + </option> + <option value="ask_before_display"> + {t('external_content_display.ask_before_display', { + keyPrefix: 'admin.legal', + })} + </option> + </Form.Select> + <Form.Text> + {t('external_content_display.text', { keyPrefix: 'admin.legal' })} + </Form.Text> + </Form.Group> <h5>{t('admin_account')}</h5> <Form.Group controlId="name" className="mb-3"> diff --git a/ui/src/pages/Install/index.tsx b/ui/src/pages/Install/index.tsx index caf40c05..f4454153 100644 --- a/ui/src/pages/Install/index.tsx +++ b/ui/src/pages/Install/index.tsx @@ -115,6 +115,11 @@ const Index: FC = () => { isInvalid: false, errorMsg: '', }, + external_content_display: { + value: 'always_display', + isInvalid: false, + errorMsg: '', + }, name: { value: '', isInvalid: false, @@ -225,10 +230,12 @@ const Index: FC = () => { site_url: formData.site_url.value, contact_email: formData.contact_email.value, login_required: formData.login_required.value, + external_content_display: formData.external_content_display.value, name: formData.name.value, password: formData.password.value, email: formData.email.value, }; + installBaseInfo(params) .then(() => { handleNext(); diff --git a/ui/src/pages/Layout/index.tsx b/ui/src/pages/Layout/index.tsx index e8bab279..4ed908c2 100644 --- a/ui/src/pages/Layout/index.tsx +++ b/ui/src/pages/Layout/index.tsx @@ -23,7 +23,12 @@ import { HelmetProvider } from 'react-helmet-async'; import { SWRConfig } from 'swr'; -import { toastStore, loginToContinueStore, errorCodeStore } from '@/stores'; +import { + toastStore, + loginToContinueStore, + errorCodeStore, + siteLealStore, +} from '@/stores'; import { Header, Footer, @@ -34,12 +39,18 @@ import { HttpErrorContent, } from '@/components'; import { LoginToContinueModal, BadgeModal } from '@/components/Modal'; -import { changeTheme } from '@/utils'; +import { changeTheme, Storage } from '@/utils'; import { useQueryNotificationStatus } from '@/services'; +import { useExternalToast } from '@/hooks'; +import { EXTERNAL_CONTENT_DISPLAY_MODE } from '@/common/constants'; const Layout: FC = () => { const location = useLocation(); const { msg: toastMsg, variant, clear: toastClear } = toastStore(); + const externalToast = useExternalToast(); + const externalContentDisplay = siteLealStore( + (state) => state.external_content_display, + ); const closeToast = () => { toastClear(); }; @@ -67,6 +78,49 @@ const Layout: FC = () => { systemThemeQuery.removeListener(handleSystemThemeChange); }; }, []); + + const replaceImgSrc = (img) => { + const storageUserExternalMode = Storage.get(EXTERNAL_CONTENT_DISPLAY_MODE); + + if ( + storageUserExternalMode !== 'always' && + img.src && + !img.src.startsWith(window.location.origin) + ) { + externalToast.onShow(); + img.dataset.src = img.src; + img.removeAttribute('src'); + } + }; + + useEffect(() => { + // Controlling the loading of external image resources + const observer = new MutationObserver((mutationsList) => { + mutationsList.forEach((mutation) => { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (node.nodeName === 'IMG') { + replaceImgSrc(node); + } + if ((node as Element).querySelectorAll) { + const images = (node as Element).querySelectorAll('img'); + images.forEach(replaceImgSrc); + } + }); + } + }); + }); + + if (externalContentDisplay !== 'always_display') { + // Process all existing images + const images = document.querySelectorAll('img'); + images.forEach(replaceImgSrc); + // Process all images added later + observer.observe(document.body, { childList: true, subtree: true }); + } + + return () => observer.disconnect(); + }, [externalContentDisplay]); return ( <HelmetProvider> <PageTags /> diff --git a/ui/src/stores/index.ts b/ui/src/stores/index.ts index cc454263..66d59b32 100644 --- a/ui/src/stores/index.ts +++ b/ui/src/stores/index.ts @@ -33,6 +33,7 @@ import loginToContinueStore from './loginToContinue'; import errorCodeStore from './errorCode'; import sideNavStore from './sideNav'; import commentReplyStore from './commentReply'; +import siteLealStore from './siteLegal'; export { toastStore, @@ -51,4 +52,5 @@ export { sideNavStore, commentReplyStore, writeSettingStore, + siteLealStore, }; diff --git a/ui/src/stores/siteLegal.ts b/ui/src/stores/siteLegal.ts new file mode 100644 index 00000000..29a26ea3 --- /dev/null +++ b/ui/src/stores/siteLegal.ts @@ -0,0 +1,38 @@ +/* + * 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 { create } from 'zustand'; + +interface LealStore { + external_content_display: string; + update: (params: { external_content_display: string }) => void; +} + +const siteLealStore = create<LealStore>((set) => ({ + external_content_display: 'always_display', + update: (params) => + set((state) => { + return { + ...state, + ...params, + }; + }), +})); + +export default siteLealStore; diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts index f3ba4235..56a576b5 100644 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@ -30,6 +30,7 @@ import { loginToContinueStore, pageTagStore, writeSettingStore, + siteLealStore, } from '@/stores'; import { RouteAlias } from '@/router/alias'; import { @@ -412,6 +413,9 @@ export const initAppSettingsStore = async () => { restrict_answer: appSettings.site_write.restrict_answer, ...appSettings.site_write, }); + siteLealStore.getState().update({ + external_content_display: appSettings.site_legal.external_content_display, + }); } };
