Просмотр исходного кода

Merge pull request #5709 from weseek/feat/gw7759-change-logo-on-top-navigation-bar

feat: Custom brand logo image
Yuki Takei 3 лет назад
Родитель
Сommit
4fd79a658b

+ 7 - 1
packages/app/public/static/locales/en_US/admin/admin.json

@@ -207,7 +207,13 @@
     "ctrl_space": "Ctrl+Space to autocomplete",
     "custom_script": "Custom script",
     "write_java": "You can write Javascript that is applied to whole system.",
-    "reflect_change": "You need to reload the page to reflect the change."
+    "reflect_change": "You need to reload the page to reflect the change.",
+    "custom_logo" : "Custom Logo",
+    "default_logo": "Default Logo",
+    "upload_logo": "Upload Logo",
+    "current_logo": "Current Logo",
+    "upload_new_logo": "Upload New Logo",
+    "delete_logo": "Delete Logo"
   },
   "importer_management": {
     "beta_warning": "This function is Beta.",

+ 6 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -1082,6 +1082,12 @@
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "manage_user_groups": "Manage user groups"
   },
+  "crop_image_modal": {
+    "image_crop": "Image Crop",
+    "crop": "Crop",
+    "reset": "Reset",
+    "cancel": "Cancel"
+  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "The list of selectable permissions could not be found. Please modify the permissions on the parent page first and try again.",

+ 7 - 1
packages/app/public/static/locales/ja_JP/admin/admin.json

@@ -207,7 +207,13 @@
     "ctrl_space": "Ctrl+Space でコード補完",
     "custom_script": "カスタムスクリプト",
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
-    "reflect_change": "変更の反映はページの更新が必要です。"
+    "reflect_change": "変更の反映はページの更新が必要です。",
+    "custom_logo": "カスタムロゴ",
+    "default_logo": "デフォルトのロゴ",
+    "upload_logo": "ロゴをアップロード",
+    "current_logo": "現在のロゴ",
+    "upload_new_logo": "新しいロゴをアップロードする",
+    "delete_logo": "ロゴを削除"
   },
   "export_management": {
     "exporting_collection_list": "エクスポート中のコレクション",

+ 6 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -1075,6 +1075,12 @@
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "manage_user_groups": "グループ管理"
   },
+  "crop_image_modal": {
+    "image_crop": "画像の切り抜き",
+    "crop": "トリミング",
+    "reset": "リセット",
+    "cancel": "キャンセル"
+  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",

+ 7 - 1
packages/app/public/static/locales/zh_CN/admin/admin.json

@@ -217,7 +217,13 @@
     "ctrl_space": "Ctrl+Space 自动完成",
     "custom_script": "定制纸条",
     "write_java": "您可以编写应用于整个系统的Javascript。",
-    "reflect_change": "您需要重新加载页面以反映更改。"
+    "reflect_change": "您需要重新加载页面以反映更改。",
+    "custom_logo": "自定义徽标",
+    "default_logo": "默认徽标",
+    "upload_logo": "上传徽标",
+    "current_logo": "当前标志",
+    "upload_new_logo": "上传新徽标",
+    "delete_logo": "删除徽标"
   },
   "importer_management": {
     "beta_warning": "这个函数是Beta。",

+ 6 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -1085,6 +1085,12 @@
     "belonging_to_no_group": "无法找到你所属的团体。",
     "manage_user_groups": "管理用户组"
   },
+  "crop_image_modal": {
+    "image_crop": "图像裁剪",
+    "crop": "修剪",
+    "reset": "重启",
+    "cancel": "取消"
+  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",

+ 4 - 0
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -17,6 +17,7 @@ import CustomizeFunctionSetting from './CustomizeFunctionSetting';
 import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeHighlightSetting from './CustomizeHighlightSetting';
 import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeLogoSetting from './CustomizeLogoSetting';
 import CustomizeScriptSetting from './CustomizeScriptSetting';
 import CustomizeSidebarSetting from './CustomizeSidebarSetting';
 import CustomizeThemeSetting from './CustomizeThemeSetting';
@@ -76,6 +77,9 @@ function Customize(props) {
       <div className="mb-5">
         <CustomizeScriptSetting />
       </div>
+      <div className="mb-5">
+        <CustomizeLogoSetting />
+      </div>
     </div>
   );
 }

+ 185 - 0
packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -0,0 +1,185 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import {
+  apiv3Delete, apiv3Get, apiv3PostForm, apiv3Put,
+} from '~/client/util/apiv3-client';
+import ImageCropModal from '~/components/Common/ImageCropModal';
+
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const DEFAULT_LOGO = '/images/logo.svg';
+
+const CustomizeLogoSetting = (): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const [uploadLogoSrc, setUploadLogoSrc] = useState<ArrayBuffer | string | null>(null);
+  const [isImageCropModalShow, setIsImageCropModalShow] = useState<boolean>(false);
+  const [isDefaultLogo, setIsDefaultLogo] = useState<boolean>(true);
+  const [retrieveError, setRetrieveError] = useState<string | null>(null);
+  const [customizedLogoSrc, setCustomizedLogoSrc] = useState< string | null >(null);
+
+  const retrieveData = useCallback(async() => {
+    try {
+      const response = await apiv3Get('/customize-setting/customize-logo');
+      const { isDefaultLogo: _isDefaultLogo, customizedLogoSrc } = response.data;
+      const isDefaultLogo = _isDefaultLogo ?? true;
+
+      setIsDefaultLogo(isDefaultLogo);
+      setCustomizedLogoSrc(customizedLogoSrc);
+    }
+    catch (err) {
+      setRetrieveError(err);
+      throw new Error('Failed to fetch data');
+    }
+  }, []);
+
+  useEffect(() => {
+    retrieveData();
+  }, [retrieveData]);
+
+  const onSelectFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    if (e.target.files != null && e.target.files.length > 0) {
+      const reader = new FileReader();
+      reader.addEventListener('load', () => setUploadLogoSrc(reader.result));
+      reader.readAsDataURL(e.target.files[0]);
+      setIsImageCropModalShow(true);
+    }
+  }, []);
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      const response = await apiv3Put('/customize-setting/customize-logo', {
+        isDefaultLogo,
+        customizedLogoSrc,
+      });
+      const { customizedParams } = response.data;
+      setIsDefaultLogo(customizedParams.isDefaultLogo);
+      setCustomizedLogoSrc(customizedParams.customizedLogoSrc);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_logo') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, isDefaultLogo, customizedLogoSrc]);
+
+
+  const onClickDeleteBtn = useCallback(async() => {
+    try {
+      await apiv3Delete('/customize-setting/delete-brand-logo');
+      setCustomizedLogoSrc(null);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.current_logo') }));
+    }
+    catch (err) {
+      toastError(err);
+      setRetrieveError(err);
+      throw new Error('Failed to delete logo');
+    }
+  }, [t]);
+
+  const onCropCompleted = useCallback(async(croppedImage) => {
+    try {
+      const formData = new FormData();
+      formData.append('file', croppedImage);
+      const { data } = await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
+      setCustomizedLogoSrc(data.attachment.filePathProxied);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.current_logo') }));
+    }
+    catch (err) {
+      toastError(err);
+      setRetrieveError(err);
+      throw new Error('Failed to upload brand logo');
+    }
+    setIsImageCropModalShow(false);
+  }, [t]);
+
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <div className="mb-5">
+            <h2 className="border-bottom my-4 admin-setting-header">{t('admin:customize_setting.custom_logo')}</h2>
+            <div className="row">
+              <div className="col-md-6 col-12 mb-3 mb-md-0">
+                <h4>
+                  <div className="custom-control custom-radio radio-primary">
+                    <input
+                      type="radio"
+                      id="radioDefaultLogo"
+                      className="custom-control-input"
+                      form="formImageType"
+                      name="imagetypeForm[isDefaultLogo]"
+                      checked={isDefaultLogo}
+                      onChange={() => { setIsDefaultLogo(true) }}
+                    />
+                    <label className="custom-control-label" htmlFor="radioDefaultLogo">
+                      {t('admin:customize_setting.default_logo')}
+                    </label>
+                  </div>
+                </h4>
+                <img src={DEFAULT_LOGO} width="64" />
+              </div>
+              <div className="col-md-6 col-12">
+                <h4>
+                  <div className="custom-control custom-radio radio-primary">
+                    <input
+                      type="radio"
+                      id="radioUploadLogo"
+                      className="custom-control-input"
+                      form="formImageType"
+                      name="imagetypeForm[isDefaultLogo]"
+                      checked={!isDefaultLogo}
+                      onChange={() => { setIsDefaultLogo(false) }}
+                    />
+                    <label className="custom-control-label" htmlFor="radioUploadLogo">
+                      { t('admin:customize_setting.upload_logo') }
+                    </label>
+                  </div>
+                </h4>
+                <div className="row mb-3">
+                  <label className="col-sm-4 col-12 col-form-label text-left">
+                    { t('admin:customize_setting.current_logo') }
+                  </label>
+                  <div className="col-sm-8 col-12">
+                    <p><img src={customizedLogoSrc || DEFAULT_LOGO} className="picture picture-lg " id="settingBrandLogo" width="64" /></p>
+                    {(customizedLogoSrc != null) && (
+                      <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
+                        { t('admin:customize_setting.delete_logo') }
+                      </button>
+                    )}
+                  </div>
+                </div>
+                <div className="row">
+                  <label className="col-sm-4 col-12 col-form-label text-left">
+                    { t('admin:customize_setting.upload_new_logo') }
+                  </label>
+                  <div className="col-sm-8 col-12">
+                    <input type="file" onChange={onSelectFile} name="brandLogo" accept="image/*" />
+                  </div>
+                </div>
+              </div>
+            </div>
+            <AdminUpdateButtonRow onClick={onClickSubmit} disabled={retrieveError != null} />
+          </div>
+        </div>
+      </div>
+
+      <ImageCropModal
+        isShow={isImageCropModalShow}
+        src={uploadLogoSrc}
+        onModalClose={() => setIsImageCropModalShow(false)}
+        onCropCompleted={onCropCompleted}
+        isCircular={false}
+      />
+    </React.Fragment>
+  );
+
+
+};
+
+
+export default CustomizeLogoSetting;

+ 127 - 0
packages/app/src/components/Common/ImageCropModal.tsx

@@ -0,0 +1,127 @@
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import canvasToBlob from 'async-canvas-to-blob';
+import { useTranslation } from 'react-i18next';
+import ReactCrop from 'react-image-crop';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { toastError } from '~/client/util/apiNotification';
+import loggerFactory from '~/utils/logger';
+import 'react-image-crop/dist/ReactCrop.css';
+
+const logger = loggerFactory('growi:ImageCropModal');
+
+interface ICropOptions {
+  aspect: number
+  unit: string,
+  x: number
+  y: number
+  width: number,
+  height: number,
+}
+
+type CropOptions = ICropOptions | null
+
+type Props = {
+  isShow: boolean,
+  src: string | ArrayBuffer | null,
+  onModalClose: () => void,
+  onCropCompleted: (res: any) => void,
+  isCircular: boolean,
+}
+const ImageCropModal: FC<Props> = (props: Props) => {
+
+  const {
+    isShow, src, onModalClose, onCropCompleted, isCircular,
+  } = props;
+
+  const [imageRef, setImageRef] = useState<HTMLImageElement>();
+  const [cropOptions, setCropOtions] = useState<CropOptions>(null);
+  const { t } = useTranslation();
+  const reset = useCallback(() => {
+    if (imageRef) {
+      const size = Math.min(imageRef.width, imageRef.height);
+      setCropOtions({
+        aspect: 1,
+        unit: 'px',
+        x: imageRef.width / 2 - size / 2,
+        y: imageRef.height / 2 - size / 2,
+        width: size,
+        height: size,
+      });
+    }
+  }, [imageRef]);
+
+  useEffect(() => {
+    document.body.style.position = 'static';
+    reset();
+  }, [reset]);
+
+  const onImageLoaded = (image) => {
+    setImageRef(image);
+    reset();
+    return false;
+  };
+
+
+  const onCropChange = (crop) => {
+    setCropOtions(crop);
+  };
+
+  const getCroppedImg = async(image, crop) => {
+    const canvas = document.createElement('canvas');
+    const scaleX = image.naturalWidth / image.width;
+    const scaleY = image.naturalHeight / image.height;
+    canvas.width = crop.width;
+    canvas.height = crop.height;
+    const ctx = canvas.getContext('2d');
+    ctx?.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width, crop.height);
+    try {
+      const blob = await canvasToBlob(canvas);
+      return blob;
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to draw image'));
+    }
+  };
+
+  const crop = async() => {
+    // crop immages
+    if (imageRef && cropOptions?.width && cropOptions.height) {
+      const result = await getCroppedImg(imageRef, cropOptions);
+      onCropCompleted(result);
+    }
+  };
+
+  return (
+    <Modal isOpen={isShow} toggle={onModalClose}>
+      <ModalHeader tag="h4" toggle={onModalClose} className="bg-info text-light">
+        {t('crop_image_modal.image_crop')}
+      </ModalHeader>
+      <ModalBody className="my-4">
+        <ReactCrop src={src} crop={cropOptions} onImageLoaded={onImageLoaded} onChange={onCropChange} circularCrop={isCircular} />
+      </ModalBody>
+      <ModalFooter>
+        <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" onClick={reset}>
+          {t('crop_image_modal.reset')}
+        </button>
+        <button type="button" className="btn btn-outline-secondary rounded-pill mr-2" onClick={onModalClose}>
+          {t('crop_image_modal.cancel')}
+        </button>
+        <button type="button" className="btn btn-outline-primary rounded-pill" onClick={crop}>
+          {t('crop_image_modal.crop')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default ImageCropModal;

+ 0 - 125
packages/app/src/components/Me/ImageCropModal.jsx

@@ -1,125 +0,0 @@
-import React from 'react';
-
-import canvasToBlob from 'async-canvas-to-blob';
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-import ReactCrop from 'react-image-crop';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
-import 'react-image-crop/dist/ReactCrop.css';
-import { toastError } from '~/client/util/apiNotification';
-import loggerFactory from '~/utils/logger';
-
-
-const logger = loggerFactory('growi:ImageCropModal');
-
-class ImageCropModal extends React.Component {
-
-  // demo: https://codesandbox.io/s/72py4jlll6
-  constructor(props) {
-    super();
-    this.state = {
-      crop: null,
-      imageRef: null,
-    };
-    this.onImageLoaded = this.onImageLoaded.bind(this);
-    this.onCropChange = this.onCropChange.bind(this);
-    this.getCroppedImg = this.getCroppedImg.bind(this);
-    this.crop = this.crop.bind(this);
-    this.reset = this.reset.bind(this);
-    this.imageRef = null;
-  }
-
-  onImageLoaded(image) {
-    this.setState({ imageRef: image }, () => this.reset());
-    return false; // Return false when setting crop state in here.
-  }
-
-  onCropChange(crop) {
-    this.setState({ crop });
-  }
-
-  async getCroppedImg(image, crop, fileName) {
-    const canvas = document.createElement('canvas');
-    const scaleX = image.naturalWidth / image.width;
-    const scaleY = image.naturalHeight / image.height;
-    canvas.width = crop.width;
-    canvas.height = crop.height;
-    const ctx = canvas.getContext('2d');
-    ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width, crop.height);
-    try {
-      const blob = await canvasToBlob(canvas);
-      return blob;
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to draw image'));
-    }
-  }
-
-  async crop() {
-    // crop immages
-    if (this.state.imageRef && this.state.crop.width && this.state.crop.height) {
-      const croppedImage = await this.getCroppedImg(this.state.imageRef, this.state.crop, '/images/icons/user');
-      this.props.onCropCompleted(croppedImage);
-    }
-  }
-
-  reset() {
-    const size = Math.min(this.state.imageRef.width, this.state.imageRef.height);
-    this.setState({
-      crop: {
-        aspect: 1,
-        unit: 'px',
-        x: this.state.imageRef.width / 2 - size / 2,
-        y: this.state.imageRef.height / 2 - size / 2,
-        width: size,
-        height: size,
-      },
-    });
-  }
-
-  render() {
-    return (
-      <Modal isOpen={this.props.show} toggle={this.props.onModalClose}>
-        <ModalHeader tag="h4" toggle={this.props.onModalClose} className="bg-info text-light">
-          Image Crop
-        </ModalHeader>
-        <ModalBody className="my-4">
-          <ReactCrop circularCrop src={this.props.src} crop={this.state.crop} onImageLoaded={this.onImageLoaded} onChange={this.onCropChange} />
-        </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" onClick={this.reset}>
-            Reset
-          </button>
-          <button type="button" className="btn btn-outline-secondary rounded-pill mr-2" onClick={this.props.onModalClose}>
-            Cancel
-          </button>
-          <button type="button" className="btn btn-outline-primary rounded-pill" onClick={this.crop}>
-            Crop
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-ImageCropModal.propTypes = {
-  show: PropTypes.bool.isRequired,
-  src: PropTypes.string,
-  onModalClose: PropTypes.func.isRequired,
-  onCropCompleted: PropTypes.func.isRequired,
-};
-
-const ImageCropModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ImageCropModal t={t} {...props} />;
-};
-
-export default ImageCropModalWrapperFC;

+ 3 - 2
packages/app/src/components/Me/ProfileImageSettings.tsx

@@ -6,10 +6,10 @@ import { useTranslation } from 'react-i18next';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost, apiPostForm } from '~/client/util/apiv1-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
+import ImageCropModal from '~/components/Common/ImageCropModal';
 import { useCurrentUser } from '~/stores/context';
 import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
 
-import ImageCropModal from './ImageCropModal';
 
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
@@ -155,10 +155,11 @@ const ProfileImageSettings = (): JSX.Element => {
       </div>
 
       <ImageCropModal
-        show={showImageCropModal}
+        isShow={showImageCropModal}
         src={imageCropSrc}
         onModalClose={() => setShowImageCropModal(false)}
         onCropCompleted={cropCompletedHandler}
+        isCircular
       />
 
       <div className="row my-3">

+ 15 - 3
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -106,12 +106,24 @@ const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
   );
 });
 
+interface NavbarLogoProps {
+  logoSrc?: string,
+}
+const GrowiNavbarLogo: FC<NavbarLogoProps> = memo((props: NavbarLogoProps) => {
+
+  const { logoSrc } = props;
+  return logoSrc != null
+    ? (<img src={logoSrc} className="picture picture-lg p-2 mx-2" id="settingBrandLogo" width="32" />)
+    : <GrowiLogo />;
+
+});
 
 const GrowiNavbar = (props) => {
 
   const { appContainer } = props;
-  const { crowi, isSearchServiceConfigured } = appContainer.config;
-
+  const {
+    crowi, isSearchServiceConfigured, customizedLogoSrc,
+  } = appContainer.config;
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isSearchPage } = useIsSearchPage();
 
@@ -120,7 +132,7 @@ const GrowiNavbar = (props) => {
       {/* Brand Logo  */}
       <div className="navbar-brand mr-0">
         <a className="grw-logo d-block" href="/">
-          <GrowiLogo />
+          <GrowiNavbarLogo logoSrc={customizedLogoSrc} />
         </a>
       </div>
 

+ 7 - 0
packages/app/src/server/interfaces/attachment.ts

@@ -0,0 +1,7 @@
+export const AttachmentType = {
+  BRAND_LOGO: 'BRAND_LOGO',
+  WIKI_PAGE: 'WIKI_PAGE',
+  PROFILE_IMAGE: 'PROFILE_IMAGE',
+} as const;
+
+export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];

+ 9 - 2
packages/app/src/server/models/attachment.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { AttachmentType } from '../interfaces/attachment';
+
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
@@ -34,6 +36,11 @@ module.exports = function(crowi) {
     fileSize: { type: Number, default: 0 },
     temporaryUrlCached: { type: String },
     temporaryUrlExpiredAt: { type: Date },
+    attachmentType: {
+      type: String,
+      enum: AttachmentType,
+      required: true,
+    },
   }, {
     timestamps: { createdAt: true, updatedAt: false },
   });
@@ -52,7 +59,7 @@ module.exports = function(crowi) {
   attachmentSchema.set('toJSON', { virtuals: true });
 
 
-  attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize) {
+  attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize, attachmentType) {
     const Attachment = this;
 
     const extname = path.extname(originalName);
@@ -68,7 +75,7 @@ module.exports = function(crowi) {
     attachment.fileName = fileName;
     attachment.fileFormat = fileFormat;
     attachment.fileSize = fileSize;
-
+    attachment.attachmentType = attachmentType;
     return attachment;
   };
 

+ 5 - 0
packages/app/src/server/models/config.ts

@@ -191,6 +191,8 @@ export const defaultNotificationConfigs: { [key: string]: any } = {
 schema.statics.getLocalconfig = function(crowi) {
   const env = process.env;
 
+  const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo');
+
   const localConfig = {
     crowi: {
       title: crowi.appService.getAppTitle(),
@@ -245,6 +247,9 @@ schema.statics.getLocalconfig = function(crowi) {
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
     pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
+    customizedLogoSrc: isDefaultLogo != null && !isDefaultLogo
+      ? crowi.configManager.getConfig('crowi', 'customize:customizedLogoSrc')
+      : null,
     auditLogEnabled: crowi.configManager.getConfig('crowi', 'app:auditLogEnabled'),
     activityExpirationSeconds: crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds'),
     auditLogAvailableActions: crowi.activityService.getAvailableActions(false),

+ 106 - 3
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -1,5 +1,7 @@
 /* eslint-disable no-unused-vars */
+
 import { SupportedAction } from '~/interfaces/activity';
+import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -13,9 +15,11 @@ const express = require('express');
 const router = express.Router();
 
 const { body, query } = require('express-validator');
+const multer = require('multer');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
+
 /**
  * @swagger
  *  tags:
@@ -100,8 +104,9 @@ module.exports = (crowi) => {
 
   const activityEvent = crowi.event('activity');
 
-  const { customizeService } = crowi;
-
+  const { customizeService, attachmentService } = crowi;
+  const Attachment = crowi.model('Attachment');
+  const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const validator = {
     layout: [
       body('isContainerFluid').isBoolean(),
@@ -146,6 +151,10 @@ module.exports = (crowi) => {
     customizeScript: [
       body('customizeScript').isString(),
     ],
+    logo: [
+      body('isDefaultLogo').isBoolean().optional({ nullable: true }),
+      body('customizedLogoSrc').isString().optional({ nullable: true }),
+    ],
   };
 
   /**
@@ -169,7 +178,6 @@ module.exports = (crowi) => {
    *                      description: customize params
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
-
     const customizeParams = {
       themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledTimeline: await crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
@@ -676,5 +684,100 @@ module.exports = (crowi) => {
     }
   });
 
+  router.get('/customize-logo', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const isDefaultLogo = await crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo');
+    const customizedLogoSrc = await crowi.configManager.getConfig('crowi', 'customize:customizedLogoSrc');
+    return res.apiv3({ isDefaultLogo, customizedLogoSrc });
+  });
+
+  router.put('/customize-logo', csrf, loginRequiredStrictly, adminRequired, validator.logo, apiV3FormValidator, async(req, res) => {
+
+    const {
+      isDefaultLogo, customizedLogoSrc,
+    } = req.body;
+
+    const requestParams = {
+      'customize:isDefaultLogo': isDefaultLogo,
+      'customize:customizedLogoSrc': customizedLogoSrc,
+    };
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        isDefaultLogo: await crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo'),
+        customizedLogoSrc: await crowi.configManager.getConfig('crowi', 'customize:customizedLogoSrc'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating customizeLogo';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-customizeLogo-failed'));
+    }
+  });
+
+  router.post('/upload-brand-logo', uploads.single('file'), loginRequiredStrictly,
+    adminRequired, csrf, validator.logo, apiV3FormValidator, async(req, res) => {
+
+      if (req.file == null) {
+        return res.apiv3Err(new ErrorV3('File error.', 'upload-brand-logo-failed'));
+      }
+      if (req.user == null) {
+        return res.apiv3Err(new ErrorV3('param "user" must be set.', 'upload-brand-logo-failed'));
+      }
+
+      const file = req.file;
+
+      // check type
+      const acceptableFileType = /image\/.+/;
+      if (!file.mimetype.match(acceptableFileType)) {
+        const msg = 'File type error. Only image files is allowed to set as user picture.';
+        return res.apiv3Err(new ErrorV3(msg, 'upload-brand-logo-failed'));
+      }
+
+      // Check if previous attachment exists and remove it
+      const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
+      if (attachments != null) {
+        await attachmentService.removeAllAttachments(attachments);
+      }
+
+      let attachment;
+      try {
+        attachment = await attachmentService.createAttachment(file, req.user, null, AttachmentType.BRAND_LOGO);
+        const attachmentConfigParams = {
+          'customize:customizedLogoSrc': attachment.filePathProxied,
+        };
+
+        await crowi.configManager.updateConfigsInTheSameNamespace('crowi', attachmentConfigParams);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.message, 'upload-brand-logo-failed'));
+      }
+      attachment.toObject({ virtuals: true });
+      return res.apiv3({ attachment });
+    });
+
+  router.delete('/delete-brand-logo', csrf, loginRequiredStrictly, adminRequired, async(req, res) => {
+
+    const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
+
+    if (attachments == null) {
+      return res.apiv3Err(new ErrorV3('attachment not found', 'delete-brand-logo-failed'));
+    }
+
+    try {
+      await attachmentService.removeAllAttachments(attachments);
+      // update attachmentId immediately
+      const attachmentConfigParams = { 'customize:customizedLogoSrc': null };
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', attachmentConfigParams);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).apiv3Err(new ErrorV3('Error while deleting logo', 'delete-brand-logo-failed'));
+    }
+
+    return res.apiv3({});
+  });
+
   return router;
 };

+ 6 - 5
packages/app/src/server/routes/attachment.js

@@ -1,7 +1,8 @@
+
 import { SupportedAction } from '~/interfaces/activity';
+import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
-
 /* eslint-disable no-use-before-define */
 
 
@@ -447,7 +448,7 @@ module.exports = function(crowi, app) {
     if (pageId == null && pagePath == null) {
       return res.json(ApiResponse.error('Either page_id or path is required.'));
     }
-    if (!req.file) {
+    if (req.file == null) {
       return res.json(ApiResponse.error('File error.'));
     }
 
@@ -473,7 +474,7 @@ module.exports = function(crowi, app) {
 
     let attachment;
     try {
-      attachment = await attachmentService.createAttachment(file, req.user, pageId);
+      attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
     }
     catch (err) {
       logger.error(err);
@@ -564,7 +565,7 @@ module.exports = function(crowi, app) {
    */
   api.uploadProfileImage = async function(req, res) {
     // check params
-    if (!req.file) {
+    if (req.file == null) {
       return res.json(ApiResponse.error('File error.'));
     }
     if (!req.user) {
@@ -582,7 +583,7 @@ module.exports = function(crowi, app) {
     let attachment;
     try {
       req.user.deleteImage();
-      attachment = await attachmentService.createAttachment(file, req.user);
+      attachment = await attachmentService.createAttachment(file, req.user, null, AttachmentType.PROFILE_IMAGE);
       await req.user.updateImage(attachment);
     }
     catch (err) {

+ 4 - 3
packages/app/src/server/service/attachment.js

@@ -1,8 +1,9 @@
 import loggerFactory from '~/utils/logger';
 
-const mongoose = require('mongoose');
 const fs = require('fs');
 
+const mongoose = require('mongoose');
+
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:AttachmentService');
 
@@ -15,7 +16,7 @@ class AttachmentService {
     this.crowi = crowi;
   }
 
-  async createAttachment(file, user, pageId = null) {
+  async createAttachment(file, user, pageId = null, attachmentType) {
     const { fileUploadService } = this.crowi;
 
     // check limit
@@ -33,7 +34,7 @@ class AttachmentService {
     // create an Attachment document and upload file
     let attachment;
     try {
-      attachment = Attachment.createWithoutSave(pageId, user, fileStream, file.originalname, file.mimetype, file.size);
+      attachment = Attachment.createWithoutSave(pageId, user, fileStream, file.originalname, file.mimetype, file.size, attachmentType);
       await fileUploadService.uploadFile(fileStream, attachment);
       await attachment.save();
     }