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,
+    });
   }
 };
 

Reply via email to