Преглед изворни кода

Merge remote-tracking branch 'origin/master' into support/prepare-v6.1.2-RC.0

Yuki Takei пре 2 година
родитељ
комит
5d863c96d1
43 измењених фајлова са 1201 додато и 748 уклоњено
  1. 2 2
      .devcontainer/docker-compose.yml
  2. 1 1
      .github/workflows/release.yml
  3. 1 1
      .github/workflows/reusable-app-build-image.yml
  4. 52 46
      CHANGELOG.md
  5. 13 11
      apps/app/package.json
  6. 8 8
      apps/app/src/client/services/AdminLocalSecurityContainer.js
  7. 10 10
      apps/app/src/client/services/AdminMarkDownContainer.js
  8. 1 1
      apps/app/src/components/Admin/AdminHome/AdminHome.jsx
  9. 6 10
      apps/app/src/components/Admin/AdminHome/EnvVarsTable.tsx
  10. 17 17
      apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.jsx
  11. 2 2
      apps/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  12. 3 3
      apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  13. 7 9
      apps/app/src/components/DuplicatedPathsTable.tsx
  14. 5 5
      apps/app/src/components/LoginForm.tsx
  15. 4 4
      apps/app/src/components/Me/BasicInfoSettings.tsx
  16. 21 13
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  17. 0 471
      apps/app/src/components/PageEditor/LinkEditModal.jsx
  18. 372 0
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  19. 4 2
      apps/app/src/pages/[[...path]].page.tsx
  20. 2 2
      apps/app/src/pages/_private-legacy-pages.page.tsx
  21. 2 2
      apps/app/src/pages/_search.page.tsx
  22. 3 3
      apps/app/src/pages/login/index.page.tsx
  23. 6 6
      apps/app/src/pages/me/[[...path]].page.tsx
  24. 2 2
      apps/app/src/pages/share/[[...path]].page.tsx
  25. 12 0
      apps/app/src/server/models/.eslintrc.js
  26. 3 3
      apps/app/src/server/models/config.ts
  27. 27 0
      apps/app/src/server/models/eslint-rules-dir/no-populate.js
  28. 24 0
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  29. 1 1
      apps/app/src/server/models/user.js
  30. 13 13
      apps/app/src/server/routes/apiv3/markdown-setting.js
  31. 6 6
      apps/app/src/server/routes/apiv3/security-setting.js
  32. 2 2
      apps/app/src/server/routes/page.js
  33. 1 0
      apps/app/src/server/service/search.ts
  34. 6 6
      apps/app/src/server/service/xss.js
  35. 2 2
      apps/app/src/services/renderer/renderer.tsx
  36. 6 6
      apps/app/src/services/xss/index.js
  37. 9 8
      apps/app/src/services/xss/xssOption.ts
  38. 2 2
      apps/app/src/stores/context.tsx
  39. 30 0
      apps/app/src/stores/modal.tsx
  40. 7 0
      apps/app/vitest.config.unit.ts
  41. 8 4
      package.json
  42. 4 0
      vitest.workspace.ts
  43. 494 64
      yarn.lock

+ 2 - 2
.devcontainer/docker-compose.yml

@@ -46,7 +46,7 @@ services:
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
     build:
-      context: ../../growi-docker-compose/elasticsearch
+      context: ../../growi-docker-compose/elasticsearch/v8
       dockerfile: ./Dockerfile
       args:
         - version=8.7.0
@@ -63,7 +63,7 @@ services:
         hard: -1
     volumes:
       - /usr/share/elasticsearch/data
-      - ../../growi-docker-compose/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
+      - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
   #need to adjust kibana version based on elasticsearch version (use same version as elasticsearch version)
   kibana:

+ 1 - 1
.github/workflows/release.yml

@@ -69,7 +69,7 @@ jobs:
         token: ${{ secrets.GITHUB_TOKEN }}
 
     - name: Delete drafts
-      uses: hugo19941994/delete-draft-releases@v1.0.0
+      uses: hugo19941994/delete-draft-releases@v1.0.1
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -36,7 +36,7 @@ jobs:
     - uses: actions/checkout@v3
 
     - name: Configure AWS Credentials
-      uses: aws-actions/configure-aws-credentials@v1
+      uses: aws-actions/configure-aws-credentials@v2
       with:
         aws-region: ap-northeast-1
         role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}

+ 52 - 46
CHANGELOG.md

@@ -17,60 +17,66 @@
 
 ## [v6.1.0](https://github.com/weseek/growi/compare/v6.0.15...v6.1.0) - 2023-05-17
 
+### BREAKING CHANGES
+
+* Node.js v14 is no longer supported.
+* Elasticsearch v6 is no longer supported.
+* imprv: Omit clobber prefix (#7627) @yuki-takei
+* support: Omit textlint (#7578) @yuki-takei
+* support: Remove Blockdiag codes (#7576) @yuki-takei
+
+See the upgrading guide for v6.1.x. => [English](https://docs.growi.org/en/admin-guide/upgrading/61x.html) / [Japanese](https://docs.growi.org/ja/admin-guide/upgrading/61x.html)
+
 ### 💎 Features
 
-- feat: Add read-only user feature (#7648) @jam411
-- feat: Support Mermaid (move into the feature dierctory) (#7647) @miya
-- feat: Fix APP_SITE_URL with an environment variable (#7646) @yuki-takei
-- feat: Support Mermaid (#7645) @miya
-- feat: Support Elasticsearch v8 (#7623) @miya
-- feat: Elasticsearchv8 module (#7623) @miya
-- feat: Bookmarks folder and sidebar menu (#7450) @mudana-grune
-- feat: GROWI Questionnaire (#7316) @hakumizuki
-- feat: Revive attachment-refs with remark (#7597) @arafubeatbox
+* feat: Add read-only user feature (#7648) @jam411
+* feat: Support Mermaid (move into the feature dierctory) (#7647) @miya
+* feat: Fix APP\_SITE\_URL with an environment variable (#7646) @yuki-takei
+* feat: Support Mermaid (#7645) @miya
+* feat: Support Elasticsearch v8 (#7623) @miya
+* feat: Elasticsearchv8 module (#7623) @miya
+* feat: Bookmarks folder and sidebar menu (#7450) @mudana-grune
+* feat: GROWI Questionnaire (#7316) @hakumizuki
+* feat: Revive attachment-refs with remark (#7597) @arafubeatbox
 
 ### 🚀 Improvement
 
-- imprv: Font size (#7663) @yuki-takei
-- imprv: Admin user can use `reset-password` without email settings (#7650) @jam411
-- imprv: Optimize fonts with next/font (#7633) @yuki-takei
-- imprv: GFM table performance 2 (#7640) @yuki-takei
-- imprv: GFM footnote styles (#7628) @yuki-takei
-- imprv: Omit clobber prefix (#7627) @yuki-takei
-- imprv: GFM table performance (#7619) @yuki-takei
-- imprv: Show unsaved warning when comment not posted (#7603) @arafubeatbox
-- imprv: Suppress UI Flickering for dropdowns (#7608) @jam411
-- imprv: Allow registering without GROWI email settings for ID/Password authentication's restricted registration (#7591) @jam411
-- imprv: Enable browsing video (for v6.1.0) (#7589) @yuki-takei
-- imprv: Show a spinner into the save button while the saving process (#7579) @yuki-takei
-- imprv: Inject PlantUML URI with config-loader (#7577) @yuki-takei
-- imprv: Loading draw.io (diagrams.net) resources (#7575) @yuki-takei
-
-### 🐛 Bug Fixes
-
-- fix: The environment variable for disabling link sharing (#7652) @yuki-takei
-- fix: Cursor resetting occurs after updating with the built-in editor (#7644) @yuki-takei
-- fix: Revision schema migration for v5 to v6 (#7637) @yuki-takei
-- fix: Editor not resetting when the same markdown (#7625) @arafubeatbox
-- support: Replaced by IAttachmentHasId (#7629) @reiji-h
-- fix: AlignRight DropdownMenu flickering (#7606) @mudana-grune
-- fix: Not display page list count badge in trash page (#7600) @yukendev
-- fix: Reverted descendant pages do not appear in search results (#7587) @miya
-- fix: Deleted descendant pages do not appear in search results (#7583) @miya
-- fix: Show lsx page list in trash page correctly (#7582) @yukendev
-- fix: Uncaught type error by `sticky-event` (#7566) @mudana-grune
+* imprv: Font size (#7663) @yuki-takei
+* imprv: Admin user can use `reset-password` without email settings (#7650) @jam411
+* imprv: Optimize fonts with next/font (#7633) @yuki-takei
+* imprv: GFM table performance 2 (#7640) @yuki-takei
+* imprv: GFM footnote styles (#7628) @yuki-takei
+* imprv: GFM table performance (#7619) @yuki-takei
+* imprv: Show unsaved warning when comment not posted (#7603) @arafubeatbox
+* imprv: Suppress UI Flickering for dropdowns (#7608) @jam411
+* imprv: Allow registering without GROWI email settings for ID/Password authentication's restricted registration (#7591) @jam411
+* imprv: Enable browsing video (for v6.1.0) (#7589) @yuki-takei
+* imprv: Show a spinner into the save button while the saving process (#7579) @yuki-takei
+* imprv: Inject PlantUML URI with config-loader (#7577) @yuki-takei
+* imprv: Loading draw.io (diagrams.net) resources (#7575) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: The environment variable for disabling link sharing (#7652) @yuki-takei
+* fix: Cursor resetting occurs after updating with the built-in editor (#7644) @yuki-takei
+* fix: Revision schema migration for v5 to v6 (#7637) @yuki-takei
+* fix: Editor not resetting when the same markdown (#7625) @arafubeatbox
+* fix: AlignRight DropdownMenu flickering (#7606) @mudana-grune
+* fix: Not display page list count badge in trash page (#7600) @yukendev
+* fix: Reverted descendant pages do not appear in search results (#7587) @miya
+* fix: Deleted descendant pages do not appear in search results (#7583) @miya
+* fix: Show lsx page list in trash page correctly (#7582) @yukendev
+* fix: Uncaught type error by `sticky-event` (#7566) @mudana-grune
 
 ### 🧰 Maintenance
 
-- support: mongoose update (#7659) @jam411
-- support: Elasticsearch8 (#7592) @miya
-- support: Replaced by IAttachmentHasId (#7629) @reiji-h
-- support: Dedupe packages (#7590) @yuki-takei
-- support: Omit textlint (#7578) @yuki-takei
-- support: Typescriptize CustomNav (#7584) @yuki-takei
-- support: Replaced by IAttachmentHasId (#7629) @reiji-h
-- support: Remove Blockdiag codes (#7576) @yuki-takei
-- support: Migrate to Turborepo (#7417) @yuki-takei
+* support: mongoose update (#7659) @jam411
+* support: Elasticsearch8 (#7592) @miya
+* support: Replaced by IAttachmentHasId (#7629) @reiji-h
+* support: Dedupe packages (#7590) @yuki-takei
+* support: Typescriptize CustomNav (#7584) @yuki-takei
+* support: Replaced by IAttachmentHasId (#7629) @reiji-h
+* support: Migrate to Turborepo (#7417) @yuki-takei
 
 ## [v6.0.15](https://github.com/weseek/growi/compare/v6.0.14...v6.0.15) - 2023-04-10
 

+ 13 - 11
apps/app/package.json

@@ -33,7 +33,9 @@
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "prelint:swagger2openapi": "yarn openapi:v3",
-    "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test": "run-p test:*",
+    "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test:vitest": "cross-env NODE_ENV=test vitest run src",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "//// misc": "",
@@ -59,14 +61,14 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.2-RC.0",
-    "@growi/hackmd": "^6.1.2-RC.0",
-    "@growi/preset-themes": "^6.1.2-RC.0",
-    "@growi/remark-attachment-refs": "^6.1.2-RC.0",
-    "@growi/remark-drawio": "^6.1.2-RC.0",
-    "@growi/remark-growi-directive": "^6.1.2-RC.0",
-    "@growi/remark-lsx": "^6.1.2-RC.0",
-    "@growi/slack": "^6.1.2-RC.0",
+    "@growi/core": "^6.1.0",
+    "@growi/hackmd": "^6.1.0",
+    "@growi/preset-themes": "^6.1.0",
+    "@growi/remark-attachment-refs": "^6.1.0",
+    "@growi/remark-drawio": "^6.1.0",
+    "@growi/remark-growi-directive": "^6.1.0",
+    "@growi/remark-lsx": "^6.1.0",
+    "@growi/slack": "^6.1.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
@@ -202,8 +204,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/presentation": "^6.1.2-RC.0",
-    "@growi/ui": "^6.1.2-RC.0",
+    "@growi/presentation": "^6.1.0",
+    "@growi/ui": "^6.1.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",

+ 8 - 8
apps/app/src/client/services/AdminLocalSecurityContainer.js

@@ -28,7 +28,7 @@ export default class AdminLocalSecurityContainer extends Container {
       retrieveError: null,
       // set dummy value tile for using suspense
       registrationMode: this.dummyRegistrationMode,
-      registrationWhiteList: [],
+      registrationWhitelist: [],
       useOnlyEnvVars: false,
       isPasswordResetEnabled: false,
       isEmailAuthenticationEnabled: false,
@@ -43,7 +43,7 @@ export default class AdminLocalSecurityContainer extends Container {
       this.setState({
         useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
         registrationMode: localSetting.registrationMode,
-        registrationWhiteList: localSetting.registrationWhiteList,
+        registrationWhitelist: localSetting.registrationWhitelist,
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
         isEmailAuthenticationEnabled: localSetting.isEmailAuthenticationEnabled,
       });
@@ -72,10 +72,10 @@ export default class AdminLocalSecurityContainer extends Container {
   }
 
   /**
-   * Change registration white list
+   * Change registration whitelist
    */
-  changeRegistrationWhiteList(value) {
-    this.setState({ registrationWhiteList: value.split('\n') });
+  changeRegistrationWhitelist(value) {
+    this.setState({ registrationWhitelist: value.split('\n') });
   }
 
   /**
@@ -96,10 +96,10 @@ export default class AdminLocalSecurityContainer extends Container {
    * update local security setting
    */
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
+    const { registrationWhitelist, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
     const response = await apiv3Put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
-      registrationWhiteList,
+      registrationWhitelist,
       isPasswordResetEnabled,
       isEmailAuthenticationEnabled,
     });
@@ -108,7 +108,7 @@ export default class AdminLocalSecurityContainer extends Container {
 
     this.setState({
       registrationMode: localSettingParams.registrationMode,
-      registrationWhiteList: localSettingParams.registrationWhiteList,
+      registrationWhitelist: localSettingParams.registrationWhitelist,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
       isEmailAuthenticationEnabled: localSettingParams.isEmailAuthenticationEnabled,
     });

+ 10 - 10
apps/app/src/client/services/AdminMarkDownContainer.js

@@ -26,8 +26,8 @@ export default class AdminMarkDownContainer extends Container {
       isIndentSizeForced: false,
       isEnabledXss: false,
       xssOption: '',
-      tagWhiteList: '',
-      attrWhiteList: '{}',
+      tagWhitelist: '',
+      attrWhitelist: '{}',
     };
 
     this.switchEnableXss = this.switchEnableXss.bind(this);
@@ -55,8 +55,8 @@ export default class AdminMarkDownContainer extends Container {
       isIndentSizeForced: markdownParams.isIndentSizeForced,
       isEnabledXss: markdownParams.isEnabledXss,
       xssOption: markdownParams.xssOption,
-      tagWhiteList: markdownParams.tagWhiteList || '',
-      attrWhiteList: markdownParams.attrWhiteList || '',
+      tagWhitelist: markdownParams.tagWhitelist || '',
+      attrWhitelist: markdownParams.attrWhitelist || '',
     });
   }
 
@@ -101,14 +101,14 @@ export default class AdminMarkDownContainer extends Container {
    * Update Xss Setting
    */
   async updateXssSetting() {
-    let { tagWhiteList } = this.state;
-    const { attrWhiteList } = this.state;
+    let { tagWhitelist } = this.state;
+    const { attrWhitelist } = this.state;
 
-    tagWhiteList = Array.isArray(tagWhiteList) ? tagWhiteList : tagWhiteList.split(',');
+    tagWhitelist = Array.isArray(tagWhitelist) ? tagWhitelist : tagWhitelist.split(',');
 
     try {
       // Check if parsing is possible
-      JSON.parse(attrWhiteList);
+      JSON.parse(attrWhitelist);
     }
     catch (err) {
       throw Error(err);
@@ -117,8 +117,8 @@ export default class AdminMarkDownContainer extends Container {
     await apiv3Put('/markdown-setting/xss', {
       isEnabledXss: this.state.isEnabledXss,
       xssOption: this.state.xssOption,
-      tagWhiteList,
-      attrWhiteList: attrWhiteList ?? '{}',
+      tagWhitelist,
+      attrWhitelist: attrWhitelist ?? '{}',
     });
   }
 

+ 1 - 1
apps/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -14,7 +14,7 @@ import loggerFactory from '~/utils/logger';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-import EnvVarsTable from './EnvVarsTable';
+import { EnvVarsTable } from './EnvVarsTable';
 import SystemInfomationTable from './SystemInfomationTable';
 
 const logger = loggerFactory('growi:admin');

+ 6 - 10
apps/app/src/components/Admin/AdminHome/EnvVarsTable.jsx → apps/app/src/components/Admin/AdminHome/EnvVarsTable.tsx

@@ -1,8 +1,11 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
-const EnvVarsTable = (props) => {
-  const envVarRows = [];
+type EnvVarsTableProps = {
+  envVars: Record<string, string | number | boolean>,
+}
+
+export const EnvVarsTable: React.FC<EnvVarsTableProps> = (props: EnvVarsTableProps) => {
+  const envVarRows: JSX.Element[] = [];
 
   for (const [key, value] of Object.entries(props.envVars)) {
     if (value != null) {
@@ -22,11 +25,4 @@ const EnvVarsTable = (props) => {
       </tbody>
     </table>
   );
-
 };
-
-EnvVarsTable.propTypes = {
-  envVars: PropTypes.object.isRequired,
-};
-
-export default EnvVarsTable;

+ 17 - 17
apps/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx → apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.jsx

@@ -8,13 +8,13 @@ import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-class WhiteListInput extends React.Component {
+class WhitelistInput extends React.Component {
 
   constructor(props) {
     super(props);
 
-    this.tagWhiteList = React.createRef();
-    this.attrWhiteList = React.createRef();
+    this.tagWhitelist = React.createRef();
+    this.attrWhitelist = React.createRef();
 
     this.tags = sanitizeDefaultSchema.tagNames;
     this.attrs = JSON.stringify(sanitizeDefaultSchema.attributes);
@@ -24,13 +24,13 @@ class WhiteListInput extends React.Component {
   }
 
   onClickRecommendTagButton() {
-    this.tagWhiteList.current.value = this.tags;
-    this.props.adminMarkDownContainer.setState({ tagWhiteList: this.tags });
+    this.tagWhitelist.current.value = this.tags;
+    this.props.adminMarkDownContainer.setState({ tagWhitelist: this.tags });
   }
 
   onClickRecommendAttrButton() {
-    this.attrWhiteList.current.value = this.attrs;
-    this.props.adminMarkDownContainer.setState({ attrWhiteList: this.attrs });
+    this.attrWhitelist.current.value = this.attrs;
+    this.props.adminMarkDownContainer.setState({ attrWhitelist: this.attrs });
   }
 
   render() {
@@ -50,9 +50,9 @@ class WhiteListInput extends React.Component {
             name="recommendedTags"
             rows="6"
             cols="40"
-            ref={this.tagWhiteList}
-            defaultValue={adminMarkDownContainer.state.tagWhiteList}
-            onChange={(e) => { adminMarkDownContainer.setState({ tagWhiteList: e.target.value }) }}
+            ref={this.tagWhitelist}
+            defaultValue={adminMarkDownContainer.state.tagWhitelist}
+            onChange={(e) => { adminMarkDownContainer.setState({ tagWhitelist: e.target.value }) }}
           />
         </div>
         <div className="mt-4">
@@ -67,9 +67,9 @@ class WhiteListInput extends React.Component {
             name="recommendedAttrs"
             rows="6"
             cols="40"
-            ref={this.attrWhiteList}
-            defaultValue={adminMarkDownContainer.state.attrWhiteList}
-            onChange={(e) => { adminMarkDownContainer.setState({ attrWhiteList: e.target.value }) }}
+            ref={this.attrWhitelist}
+            defaultValue={adminMarkDownContainer.state.attrWhitelist}
+            onChange={(e) => { adminMarkDownContainer.setState({ attrWhitelist: e.target.value }) }}
           />
         </div>
       </>
@@ -79,7 +79,7 @@ class WhiteListInput extends React.Component {
 }
 
 
-WhiteListInput.propTypes = {
+WhitelistInput.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
 
@@ -88,9 +88,9 @@ WhiteListInput.propTypes = {
 const PresentationFormWrapperFC = (props) => {
   const { t } = useTranslation('admin');
 
-  return <WhiteListInput t={t} {...props} />;
+  return <WhitelistInput t={t} {...props} />;
 };
 
-const WhiteListWrapper = withUnstatedContainers(PresentationFormWrapperFC, [AdminMarkDownContainer]);
+const WhitelistWrapper = withUnstatedContainers(PresentationFormWrapperFC, [AdminMarkDownContainer]);
 
-export default WhiteListWrapper;
+export default WhitelistWrapper;

+ 2 - 2
apps/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -12,7 +12,7 @@ import loggerFactory from '~/utils/logger';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-import WhiteListInput from './WhiteListInput';
+import WhitelistInput from './WhitelistInput';
 
 const logger = loggerFactory('growi:importer');
 
@@ -102,7 +102,7 @@ class XssForm extends React.Component {
               />
               <label className="custom-control-label w-100" htmlFor="xssOption2">
                 <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
-                <WhiteListInput customizable />
+                <WhitelistInput customizable />
               </label>
             </div>
           </div>

+ 3 - 3
apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -146,9 +146,9 @@ class LocalSecuritySettingContents extends React.Component {
                 <textarea
                   className="form-control"
                   type="textarea"
-                  name="registrationWhiteList"
-                  defaultValue={adminLocalSecurityContainer.state.registrationWhiteList.join('\n')}
-                  onChange={e => adminLocalSecurityContainer.changeRegistrationWhiteList(e.target.value)}
+                  name="registrationWhitelist"
+                  defaultValue={adminLocalSecurityContainer.state.registrationWhitelist.join('\n')}
+                  onChange={e => adminLocalSecurityContainer.changeRegistrationWhitelist(e.target.value)}
                 />
                 <p className="form-text text-muted small">
                   {t('security_settings.restrict_emails')}

+ 7 - 9
apps/app/src/components/DuplicatedPathsTable.jsx → apps/app/src/components/DuplicatedPathsTable.tsx

@@ -2,12 +2,17 @@ import React from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 
 
 const { convertToNewAffiliationPath } = pagePathUtils;
 
-function DuplicatedPathsTable(props) {
+type DuplicatedPathsTableProps = {
+  existingPaths: string[],
+  fromPath: string,
+  toPath: string
+}
+
+const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: DuplicatedPathsTableProps) => {
   const { t } = useTranslation();
 
   const {
@@ -41,13 +46,6 @@ function DuplicatedPathsTable(props) {
       </tbody>
     </table>
   );
-}
-
-
-DuplicatedPathsTable.propTypes = {
-  existingPaths: PropTypes.array.isRequired,
-  fromPath: PropTypes.string.isRequired,
-  toPath: PropTypes.string.isRequired,
 };
 
 

+ 5 - 5
apps/app/src/components/LoginForm.tsx

@@ -25,7 +25,7 @@ type LoginFormProps = {
   email?: string,
   isEmailAuthenticationEnabled: boolean,
   registrationMode: RegistrationMode,
-  registrationWhiteList: string[],
+  registrationWhitelist: string[],
   isPasswordResetEnabled: boolean,
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
@@ -41,7 +41,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
   const {
     isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled,
-    isEmailAuthenticationEnabled, registrationMode, registrationWhiteList, isMailerSetup, objOfIsExternalAuthEnableds,
+    isEmailAuthenticationEnabled, registrationMode, registrationWhitelist, isMailerSetup, objOfIsExternalAuthEnableds,
   } = props;
   const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
   const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
@@ -439,11 +439,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             />
           </div>
 
-          {registrationWhiteList.length > 0 && (
+          {registrationWhitelist.length > 0 && (
             <>
               <p className="form-text">{t('page_register.form_help.email')}</p>
               <ul>
-                {registrationWhiteList.map((elem) => {
+                {registrationWhitelist.map((elem) => {
                   return (
                     <li key={elem}>
                       <code>{elem}</code>
@@ -503,7 +503,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     );
   }, [
     t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration,
-    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhiteList, switchForm, handleRegisterFormSubmit,
+    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhitelist, switchForm, handleRegisterFormSubmit,
   ]);
 
   if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {

+ 4 - 4
apps/app/src/components/Me/BasicInfoSettings.tsx

@@ -5,12 +5,12 @@ import { useTranslation, i18n } from 'next-i18next';
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useRegistrationWhiteList } from '~/stores/context';
+import { useRegistrationWhitelist } from '~/stores/context';
 import { usePersonalSettings } from '~/stores/personal-settings';
 
 export const BasicInfoSettings = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data: registrationWhiteList } = useRegistrationWhiteList();
+  const { data: registrationWhitelist } = useRegistrationWhitelist();
 
   const {
     data: personalSettingsInfo, mutate: mutatePersonalSettings, sync, updateBasicInfo, error,
@@ -63,11 +63,11 @@ export const BasicInfoSettings = (): JSX.Element => {
             defaultValue={personalSettingsInfo?.email || ''}
             onChange={e => changePersonalSettingsHandler({ email: e.target.value })}
           />
-          {registrationWhiteList != null && registrationWhiteList.length !== 0 && (
+          {registrationWhitelist != null && registrationWhitelist.length !== 0 && (
             <div className="form-text text-muted">
               {t('page_register.form_help.email')}
               <ul>
-                {registrationWhiteList.map(data => <li key={data}><code>{data}</code></li>)}
+                {registrationWhitelist.map(data => <li key={data}><code>{data}</code></li>)}
               </ul>
             </div>
           )}

+ 21 - 13
apps/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -9,7 +9,9 @@ import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
-import { useHandsontableModal, useDrawioModal, useTemplateModal } from '~/stores/modal';
+import {
+  useHandsontableModal, useDrawioModal, useTemplateModal, useLinkEditModal,
+} from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
@@ -22,7 +24,6 @@ import EmojiPickerHelper from './EmojiPickerHelper';
 import GridEditModal from './GridEditModal';
 // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
 // import geu from './GridEditorUtil';
-import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import markdownLinkUtil from './MarkdownLinkUtil';
 import markdownListUtil from './MarkdownListUtil';
@@ -149,13 +150,13 @@ class CodeMirrorEditor extends AbstractEditor {
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
     // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
     // this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
-    this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.clickDrawioIconHandler = this.clickDrawioIconHandler.bind(this);
     this.clickTableIconHandler = this.clickTableIconHandler.bind(this);
 
     this.showTemplateModal = this.showTemplateModal.bind(this);
+    this.showLinkEditModal = this.showLinkEditModal.bind(this);
 
   }
 
@@ -846,15 +847,21 @@ class CodeMirrorEditor extends AbstractEditor {
   //   this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
   // }
 
-  showLinkEditHandler() {
-    this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
-  }
-
   showTemplateModal() {
     const onSubmit = templateText => this.setValue(templateText);
     this.props.onClickTemplateBtn(onSubmit);
   }
 
+  showLinkEditModal() {
+    const onSubmit = (linkText) => {
+      return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText);
+    };
+
+    const defaultMarkdownLink = markdownLinkUtil.getMarkdownLink(this.getCodeMirror());
+
+    this.props.onClickLinkEditBtn(defaultMarkdownLink, onSubmit);
+  }
+
   // fold draw.io section (``` drawio ~ ```)
   foldDrawioSection() {
     const editor = this.getCodeMirror();
@@ -985,7 +992,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Link"
-        onClick={this.showLinkEditHandler}
+        onClick={this.showLinkEditModal}
       >
         <EditorIcon icon="Link" />
       </Button>,
@@ -1125,11 +1132,6 @@ class CodeMirrorEditor extends AbstractEditor {
           onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
         />
          */}
-
-        <LinkEditModal
-          ref={this.linkEditModal}
-          onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
-        />
       </div>
     );
   }
@@ -1154,6 +1156,7 @@ const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openHandsontableModal } = useHandsontableModal();
   const { open: openTemplateModal } = useTemplateModal();
+  const { open: openLinkEditModal } = useLinkEditModal();
 
   const openDrawioModalHandler = useCallback((drawioMxFile, onSave) => {
     openDrawioModal(drawioMxFile, onSave);
@@ -1167,12 +1170,17 @@ const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
     openTemplateModal(onSubmit);
   }, [openTemplateModal]);
 
+  const openLinkEditModalHandler = useCallback((defaultMarkdownLink, onSubmit) => {
+    openLinkEditModal(defaultMarkdownLink, onSubmit);
+  }, [openLinkEditModal]);
+
   return (
     <CodeMirrorEditorMemoized
       ref={ref}
       onClickDrawioBtn={openDrawioModalHandler}
       onClickTableBtn={openTableModalHandler}
       onClickTemplateBtn={openTemplateModalHandler}
+      onClickLinkEditBtn={openLinkEditModalHandler}
       {...props}
     />
   );

+ 0 - 471
apps/app/src/components/PageEditor/LinkEditModal.jsx

@@ -1,471 +0,0 @@
-import React from 'react';
-
-import path from 'path';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-  Popover,
-  PopoverBody,
-} from 'reactstrap';
-import validator from 'validator';
-
-
-import Linker from '~/client/models/Linker';
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { useCurrentPagePath } from '~/stores/page';
-import loggerFactory from '~/utils/logger';
-
-import PagePreviewIcon from '../Icons/PagePreviewIcon';
-import SearchTypeahead from '../SearchTypeahead';
-
-import Preview from './Preview';
-
-
-import styles from './LinkEditPreview.module.scss';
-
-
-const logger = loggerFactory('growi:components:LinkEditModal');
-
-class LinkEditModal extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      show: false,
-      isUseRelativePath: false,
-      isUsePermanentLink: false,
-      linkInputValue: '',
-      labelInputValue: '',
-      linkerType: Linker.types.markdownLink,
-      markdown: null,
-      pagePath: null,
-      previewError: '',
-      permalink: '',
-      isPreviewOpen: false,
-    };
-
-    // this.isApplyPukiwikiLikeLinkerPlugin = window.growiRenderer.preProcessors.some(process => process.constructor.name === 'PukiwikiLikeLinker');
-
-    this.show = this.show.bind(this);
-    this.hide = this.hide.bind(this);
-    this.cancel = this.cancel.bind(this);
-    this.handleChangeTypeahead = this.handleChangeTypeahead.bind(this);
-    this.handleChangeLabelInput = this.handleChangeLabelInput.bind(this);
-    this.handleChangeLinkInput = this.handleChangeLinkInput.bind(this);
-    this.handleSelecteLinkerType = this.handleSelecteLinkerType.bind(this);
-    this.toggleIsUseRelativePath = this.toggleIsUseRelativePath.bind(this);
-    this.toggleIsUsePamanentLink = this.toggleIsUsePamanentLink.bind(this);
-    this.save = this.save.bind(this);
-    this.generateLink = this.generateLink.bind(this);
-    this.getRootPath = this.getRootPath.bind(this);
-    this.toggleIsPreviewOpen = this.toggleIsPreviewOpen.bind(this);
-    this.setMarkdown = this.setMarkdown.bind(this);
-  }
-
-  // defaultMarkdownLink is an instance of Linker
-  show(defaultMarkdownLink = null) {
-    // if defaultMarkdownLink is null, set default value in inputs.
-    const { label = '', link = '' } = defaultMarkdownLink;
-    let { type = Linker.types.markdownLink } = defaultMarkdownLink;
-
-    // if type of defaultMarkdownLink is pukiwikiLink when pukiwikiLikeLinker plugin is disable, change type(not change label and link)
-    if (type === Linker.types.pukiwikiLink && !this.isApplyPukiwikiLikeLinkerPlugin) {
-      type = Linker.types.markdownLink;
-    }
-
-    this.parseLinkAndSetState(link, type);
-
-    this.setState({
-      show: true,
-      labelInputValue: label,
-      isUsePermanentLink: false,
-      permalink: '',
-      linkerType: type,
-    });
-  }
-
-  // parse link, link is ...
-  // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
-  // case-2. absolute path of this growi's page (ex. '/hoge/fuga')
-  // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
-  // case-4. external link (ex. 'https://growi.org')
-  // case-5. the others (ex. '')
-  parseLinkAndSetState(link, type) {
-    // create url from link, add dummy origin if link is not valid url.
-    // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
-    // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
-    let isFqcn = false;
-    let isUseRelativePath = false;
-    let url;
-    try {
-      const url = new URL(link, 'http://example.com');
-      isFqcn = url.origin !== 'http://example.com';
-    }
-    catch (err) {
-      logger.debug(err);
-    }
-
-    // case-1: when link is this growi's page url, return pathname only
-    let reshapedLink = url != null && url.origin === window.location.origin
-      ? decodeURIComponent(url.pathname)
-      : link;
-
-    // case-3
-    if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') {
-      isUseRelativePath = true;
-      const rootPath = this.getRootPath(type);
-      reshapedLink = path.resolve(rootPath, reshapedLink);
-    }
-
-    this.setState({
-      linkInputValue: reshapedLink,
-      isUseRelativePath,
-    });
-  }
-
-  cancel() {
-    this.hide();
-  }
-
-  hide() {
-    this.setState({
-      show: false,
-    });
-  }
-
-  toggleIsUseRelativePath() {
-    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
-      return;
-    }
-
-    // User can't use both relativePath and permalink at the same time
-    this.setState({ isUseRelativePath: !this.state.isUseRelativePath, isUsePermanentLink: false });
-  }
-
-  toggleIsUsePamanentLink() {
-    if (this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink) {
-      return;
-    }
-
-    // User can't use both relativePath and permalink at the same time
-    this.setState({ isUsePermanentLink: !this.state.isUsePermanentLink, isUseRelativePath: false });
-  }
-
-  async setMarkdown() {
-    const { t } = this.props;
-    const path = this.state.linkInputValue;
-    let markdown = null;
-    let pagePath = null;
-    let permalink = '';
-    let previewError = '';
-
-    if (path.startsWith('/')) {
-      try {
-        const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
-        const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1));
-        const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
-
-        const { data } = await apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
-        const { page } = data;
-        markdown = page.revision.body;
-        pagePath = page.path;
-        permalink = page.id;
-      }
-      catch (err) {
-        previewError = err.message;
-      }
-    }
-    else {
-      previewError = t('link_edit.page_not_found_in_preview', { path });
-    }
-    this.setState({
-      markdown, pagePath, previewError, permalink,
-    });
-  }
-
-  renderLinkPreview() {
-    const linker = this.generateLink();
-    return (
-      <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
-        <div className="card card-disabled w-100 p-1 mb-0">
-          <p className="text-left text-muted mb-1 small">Markdown</p>
-          <p className="text-center text-truncate text-muted">{linker.generateMarkdownText()}</p>
-        </div>
-        <div className="d-flex align-items-center justify-content-center">
-          <span className="lead mx-3">
-            <i className="d-none d-sm-block fa fa-caret-right"></i>
-            <i className="d-sm-none fa fa-caret-down"></i>
-          </span>
-        </div>
-        <div className="card w-100 p-1 mb-0">
-          <p className="text-left text-muted mb-1 small">HTML</p>
-          <p className="text-center text-truncate">
-            <a href={linker.link}>{linker.label}</a>
-          </p>
-        </div>
-      </div>
-    );
-  }
-
-  handleChangeTypeahead(selected) {
-    const pageWithMeta = selected[0];
-    if (pageWithMeta != null) {
-      const page = pageWithMeta.data;
-      const permalink = `${window.location.origin}/${page.id}`;
-      this.setState({ linkInputValue: page.path, permalink });
-    }
-  }
-
-  handleChangeLabelInput(label) {
-    this.setState({ labelInputValue: label });
-  }
-
-  handleChangeLinkInput(link) {
-    let isUseRelativePath = this.state.isUseRelativePath;
-    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
-      isUseRelativePath = false;
-    }
-    this.setState({
-      linkInputValue: link, isUseRelativePath, isUsePermanentLink: false, permalink: '',
-    });
-  }
-
-  handleSelecteLinkerType(linkerType) {
-    let { isUseRelativePath, isUsePermanentLink } = this.state;
-    if (linkerType === Linker.types.growiLink) {
-      isUseRelativePath = false;
-      isUsePermanentLink = false;
-    }
-    this.setState({ linkerType, isUseRelativePath, isUsePermanentLink });
-  }
-
-  save() {
-    const linker = this.generateLink();
-
-    if (this.props.onSave != null) {
-      this.props.onSave(linker.generateMarkdownText());
-    }
-
-    this.hide();
-  }
-
-  generateLink() {
-    const {
-      linkInputValue, labelInputValue, linkerType, isUseRelativePath, isUsePermanentLink, permalink,
-    } = this.state;
-
-    let reshapedLink = linkInputValue;
-    if (isUseRelativePath) {
-      const rootPath = this.getRootPath(linkerType);
-      reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
-    }
-
-    if (isUsePermanentLink && permalink != null) {
-      reshapedLink = permalink;
-    }
-
-    return new Linker(linkerType, labelInputValue, reshapedLink);
-  }
-
-  getRootPath(type) {
-    const { pagePath } = this.props;
-    // rootPaths of md link and pukiwiki link are different
-    return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
-  }
-
-  async toggleIsPreviewOpen() {
-    // open popover
-    if (this.state.isPreviewOpen === false) {
-      this.setMarkdown();
-    }
-    this.setState({ isPreviewOpen: !this.state.isPreviewOpen });
-  }
-
-  renderLinkAndLabelForm() {
-    const { t } = this.props;
-    const { pagePath } = this.state;
-
-    return (
-      <>
-        <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
-        <form className="form-group">
-          <div className="form-gorup my-3">
-            <div className="input-group flex-nowrap">
-              <div className="input-group-prepend">
-                <span className="input-group-text">{t('link_edit.link')}</span>
-              </div>
-              <SearchTypeahead
-                onChange={this.handleChangeTypeahead}
-                onInputChange={this.handleChangeLinkInput}
-                inputName="link"
-                placeholder={t('link_edit.placeholder_of_link_input')}
-                keywordOnInit={this.state.linkInputValue}
-                autoFocus
-              />
-              <div className="d-none d-sm-block input-group-append">
-                <button type="button" id="preview-btn" className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}>
-                  <PagePreviewIcon />
-                </button>
-                <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
-                  <PopoverBody>
-                    {this.state.markdown != null && pagePath != null
-                    && <div className={`linkedit-preview ${styles['linkedit-preview']}`}>
-                      <Preview markdown={this.state.markdown} pagePath={pagePath} />
-                    </div>
-                    }
-                  </PopoverBody>
-                </Popover>
-              </div>
-            </div>
-          </div>
-          <div className="form-gorup my-3">
-            <div className="input-group flex-nowrap">
-              <div className="input-group-prepend">
-                <span className="input-group-text">{t('link_edit.label')}</span>
-              </div>
-              <input
-                type="text"
-                className="form-control"
-                id="label"
-                value={this.state.labelInputValue}
-                onChange={e => this.handleChangeLabelInput(e.target.value)}
-                disabled={this.state.linkerType === Linker.types.growiLink}
-                placeholder={this.state.linkInputValue}
-              />
-            </div>
-          </div>
-        </form>
-      </>
-    );
-  }
-
-  renderPathFormatForm() {
-    const { t } = this.props;
-    return (
-      <div className="card well pt-3">
-        <form className="form-group mb-0">
-          <div className="form-group mb-0 row">
-            <label className="col-sm-3">{t('link_edit.path_format')}</label>
-            <div className="col-sm-9">
-              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
-                <input
-                  className="custom-control-input"
-                  id="relativePath"
-                  type="checkbox"
-                  checked={this.state.isUseRelativePath}
-                  onChange={this.toggleIsUseRelativePath}
-                  disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
-                />
-                <label className="custom-control-label" htmlFor="relativePath">
-                  {t('link_edit.use_relative_path')}
-                </label>
-              </div>
-              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
-                <input
-                  className="custom-control-input"
-                  id="permanentLink"
-                  type="checkbox"
-                  checked={this.state.isUsePermanentLink}
-                  onChange={this.toggleIsUsePamanentLink}
-                  disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
-                />
-                <label className="custom-control-label" htmlFor="permanentLink">
-                  {t('link_edit.use_permanent_link')}
-                </label>
-              </div>
-            </div>
-          </div>
-          {this.isApplyPukiwikiLikeLinkerPlugin && (
-            <div className="form-group row mb-0 mt-1">
-              <label className="col-sm-3">{t('link_edit.notation')}</label>
-              <div className="col-sm-9">
-                <div className="custom-control custom-radio custom-control-inline">
-                  <input
-                    type="radio"
-                    className="custom-control-input"
-                    id="markdownType"
-                    value={Linker.types.markdownLink}
-                    checked={this.state.linkerType === Linker.types.markdownLink}
-                    onChange={e => this.handleSelecteLinkerType(e.target.value)}
-                  />
-                  <label className="custom-control-label" htmlFor="markdownType">
-                    {t('link_edit.markdown')}
-                  </label>
-                </div>
-                <div className="custom-control custom-radio custom-control-inline">
-                  <input
-                    type="radio"
-                    className="custom-control-input"
-                    id="pukiwikiType"
-                    value={Linker.types.pukiwikiLink}
-                    checked={this.state.linkerType === Linker.types.pukiwikiLink}
-                    onChange={e => this.handleSelecteLinkerType(e.target.value)}
-                  />
-                  <label className="custom-control-label" htmlFor="pukiwikiType">
-                    {t('link_edit.pukiwiki')}
-                  </label>
-                </div>
-              </div>
-            </div>
-          )}
-        </form>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-    return (
-      <Modal className="link-edit-modal" isOpen={this.state.show} toggle={this.cancel} size="lg" autoFocus={false}>
-        <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
-          {t('link_edit.edit_link')}
-        </ModalHeader>
-
-        <ModalBody className="container">
-          <div className="row">
-            <div className="col-12">
-              {this.renderLinkAndLabelForm()}
-              {this.renderPathFormatForm()}
-            </div>
-          </div>
-          <div className="row">
-            <div className="col-12">
-              <h3 className="grw-modal-head">{t('link_edit.preview')}</h3>
-              {this.renderLinkPreview()}
-            </div>
-          </div>
-        </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={this.hide}>
-            {t('Cancel')}
-          </button>
-          <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
-            {t('Done')}
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-const LinkEditModalFc = React.memo(React.forwardRef((props, ref) => {
-  const { t } = useTranslation();
-  const { data: currentPath } = useCurrentPagePath();
-  return <LinkEditModal t={t} ref={ref} pagePath={currentPath} {...props} />;
-}));
-
-LinkEditModal.propTypes = {
-  t: PropTypes.func.isRequired,
-  pagePath: PropTypes.string,
-  onSave: PropTypes.func,
-};
-
-
-export default LinkEditModalFc;

+ 372 - 0
apps/app/src/components/PageEditor/LinkEditModal.tsx

@@ -0,0 +1,372 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import path from 'path';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+  Popover,
+  PopoverBody,
+} from 'reactstrap';
+import validator from 'validator';
+
+
+import Linker from '~/client/models/Linker';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { useLinkEditModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
+import { usePreviewOptions } from '~/stores/renderer';
+import loggerFactory from '~/utils/logger';
+
+import PagePreviewIcon from '../Icons/PagePreviewIcon';
+import SearchTypeahead from '../SearchTypeahead';
+
+import Preview from './Preview';
+
+
+import styles from './LinkEditPreview.module.scss';
+
+
+const logger = loggerFactory('growi:components:LinkEditModal');
+
+export const LinkEditModal = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: rendererOptions } = usePreviewOptions();
+  const { data: linkEditModalStatus, close } = useLinkEditModal();
+
+  const [isUseRelativePath, setIsUseRelativePath] = useState<boolean>(false);
+  const [isUsePermanentLink, setIsUsePermanentLink] = useState<boolean>(false);
+  const [linkInputValue, setLinkInputValue] = useState<string>('');
+  const [labelInputValue, setLabelInputValue] = useState<string>('');
+  const [linkerType, setLinkerType] = useState<string>('');
+  const [markdown, setMarkdown] = useState<string>('');
+  const [pagePath, setPagePath] = useState<string>('');
+  const [previewError, setPreviewError] = useState<string>();
+  const [permalink, setPermalink] = useState<string>('');
+  const [isPreviewOpen, setIsPreviewOpen] = useState<boolean>(false);
+
+  const getRootPath = useCallback((type: string) => {
+    // rootPaths of md link and pukiwiki link are different
+    if (currentPath == null) return '';
+    return type === Linker.types.markdownLink ? path.dirname(currentPath) : currentPath;
+  }, [currentPath]);
+
+  // parse link, link is ...
+  // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
+  // case-2. absolute path of this growi's page (ex. '/hoge/fuga')
+  // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
+  // case-4. external link (ex. 'https://growi.org')
+  // case-5. the others (ex. '')
+  const parseLinkAndSetState = useCallback((link: string, type: string) => {
+    // create url from link, add dummy origin if link is not valid url.
+    // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
+    // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
+    let isFqcn = false;
+    let isUseRelativePath = false;
+    let url;
+    try {
+      const url = new URL(link, 'http://example.com');
+      isFqcn = url.origin !== 'http://example.com';
+    }
+    catch (err) {
+      logger.debug(err);
+    }
+
+    // case-1: when link is this growi's page url, return pathname only
+    let reshapedLink = url != null && url.origin === window.location.origin
+      ? decodeURIComponent(url.pathname)
+      : link;
+
+    // case-3
+    if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') {
+      isUseRelativePath = true;
+      const rootPath = getRootPath(type);
+      reshapedLink = path.resolve(rootPath, reshapedLink);
+    }
+
+    setLinkInputValue(reshapedLink);
+    setIsUseRelativePath(isUseRelativePath);
+  }, [getRootPath]);
+
+  useEffect(() => {
+    if (linkEditModalStatus == null) { return }
+    const { label = '', link = '' } = linkEditModalStatus.defaultMarkdownLink ?? {};
+    const { type = Linker.types.markdownLink } = linkEditModalStatus.defaultMarkdownLink ?? {};
+
+    parseLinkAndSetState(link, type);
+    setLabelInputValue(label);
+    setIsUsePermanentLink(false);
+    setPermalink('');
+    setLinkerType(type);
+
+  }, [linkEditModalStatus, parseLinkAndSetState]);
+
+  const toggleIsUseRelativePath = () => {
+    if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    setIsUseRelativePath(!isUseRelativePath);
+    setIsUsePermanentLink(false);
+  };
+
+  const toggleIsUsePamanentLink = () => {
+    if (permalink === '' || linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    setIsUsePermanentLink(!isUsePermanentLink);
+    setIsUseRelativePath(false);
+  };
+
+  const setMarkdownHandler = async() => {
+    const path = linkInputValue;
+    let markdown = '';
+    let pagePath = '';
+    let permalink = '';
+
+    if (path.startsWith('/')) {
+      try {
+        const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
+        const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1));
+        const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
+
+        const { data } = await apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
+        const { page } = data;
+        markdown = page.revision.body;
+        pagePath = page.path;
+        permalink = page.id;
+      }
+      catch (err) {
+        setPreviewError(err.message);
+      }
+    }
+    else {
+      setPreviewError(t('link_edit.page_not_found_in_preview', { path }));
+    }
+
+    setMarkdown(markdown);
+    setPagePath(pagePath);
+    setPermalink(permalink);
+  };
+
+  const generateLink = () => {
+
+    let reshapedLink = linkInputValue;
+    if (isUseRelativePath) {
+      const rootPath = getRootPath(linkerType);
+      reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
+    }
+
+    if (isUsePermanentLink && permalink != null) {
+      reshapedLink = permalink;
+    }
+
+    return new Linker(linkerType, labelInputValue, reshapedLink);
+  };
+
+  const renderLinkPreview = (): JSX.Element => {
+    const linker = generateLink();
+    return (
+      <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
+        <div className="card card-disabled w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">Markdown</p>
+          <p className="text-center text-truncate text-muted">{linker.generateMarkdownText()}</p>
+        </div>
+        <div className="d-flex align-items-center justify-content-center">
+          <span className="lead mx-3">
+            <i className="d-none d-sm-block fa fa-caret-right"></i>
+            <i className="d-sm-none fa fa-caret-down"></i>
+          </span>
+        </div>
+        <div className="card w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">HTML</p>
+          <p className="text-center text-truncate">
+            <a href={linker.link}>{linker.label}</a>
+          </p>
+        </div>
+      </div>
+    );
+  };
+
+  const handleChangeTypeahead = (selected) => {
+    const pageWithMeta = selected[0];
+    if (pageWithMeta != null) {
+      const page = pageWithMeta.data;
+      const permalink = `${window.location.origin}/${page.id}`;
+      setLinkInputValue(page.path);
+      setPermalink(permalink);
+    }
+  };
+
+  const handleChangeLabelInput = (label: string) => {
+    setLabelInputValue(label);
+  };
+
+  const handleChangeLinkInput = (link) => {
+    let useRelativePath = isUseRelativePath;
+    if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
+      useRelativePath = false;
+    }
+    setLinkInputValue(link);
+    setIsUseRelativePath(useRelativePath);
+    setIsUsePermanentLink(false);
+    setPermalink('');
+  };
+
+  const save = () => {
+    const linker = generateLink();
+
+    if (linkEditModalStatus?.onSave != null) {
+      linkEditModalStatus.onSave(linker.generateMarkdownText() ?? '');
+    }
+
+    close();
+  };
+
+  const toggleIsPreviewOpen = async() => {
+    // open popover
+    if (!isPreviewOpen) {
+      setMarkdownHandler();
+    }
+    setIsPreviewOpen(!isPreviewOpen);
+  };
+
+  const renderLinkAndLabelForm = (): JSX.Element => {
+    return (
+      <>
+        <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
+        <form className="form-group">
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">{t('link_edit.link')}</span>
+              </div>
+              <SearchTypeahead
+                onChange={handleChangeTypeahead}
+                onInputChange={handleChangeLinkInput}
+                placeholder={t('link_edit.placeholder_of_link_input')}
+                keywordOnInit={linkInputValue}
+                autoFocus
+              />
+              <div className="d-none d-sm-block input-group-append">
+                <button type="button" id="preview-btn" className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}>
+                  <PagePreviewIcon />
+                </button>
+                <Popover trigger="focus" placement="right" isOpen={isPreviewOpen} target="preview-btn" toggle={toggleIsPreviewOpen}>
+                  <PopoverBody>
+                    {markdown != null && pagePath != null && rendererOptions != null
+                    && <div className={`linkedit-preview ${styles['linkedit-preview']}`}>
+                      <Preview markdown={markdown} pagePath={pagePath} rendererOptions={rendererOptions} />
+                    </div>
+                    }
+                  </PopoverBody>
+                </Popover>
+              </div>
+            </div>
+          </div>
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">{t('link_edit.label')}</span>
+              </div>
+              <input
+                type="text"
+                className="form-control"
+                id="label"
+                value={labelInputValue}
+                onChange={e => handleChangeLabelInput(e.target.value)}
+                disabled={linkerType === Linker.types.growiLink}
+                placeholder={linkInputValue}
+              />
+            </div>
+          </div>
+        </form>
+      </>
+    );
+  };
+
+  const renderPathFormatForm = (): JSX.Element => {
+    return (
+      <div className="card well pt-3">
+        <form className="form-group mb-0">
+          <div className="form-group mb-0 row">
+            <label className="col-sm-3">{t('link_edit.path_format')}</label>
+            <div className="col-sm-9">
+              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+                <input
+                  className="custom-control-input"
+                  id="relativePath"
+                  type="checkbox"
+                  checked={isUseRelativePath}
+                  onChange={toggleIsUseRelativePath}
+                  disabled={!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink}
+                />
+                <label className="custom-control-label" htmlFor="relativePath">
+                  {t('link_edit.use_relative_path')}
+                </label>
+              </div>
+              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+                <input
+                  className="custom-control-input"
+                  id="permanentLink"
+                  type="checkbox"
+                  checked={isUsePermanentLink}
+                  onChange={toggleIsUsePamanentLink}
+                  disabled={permalink === '' || linkerType === Linker.types.growiLink}
+                />
+                <label className="custom-control-label" htmlFor="permanentLink">
+                  {t('link_edit.use_permanent_link')}
+                </label>
+              </div>
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  };
+
+  if (linkEditModalStatus == null) {
+    return <></>;
+  }
+
+  return (
+    <Modal className="link-edit-modal" isOpen={linkEditModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        {t('link_edit.edit_link')}
+      </ModalHeader>
+
+      <ModalBody className="container">
+        <div className="row">
+          <div className="col-12">
+            {renderLinkAndLabelForm()}
+            {renderPathFormatForm()}
+          </div>
+        </div>
+        <div className="row">
+          <div className="col-12">
+            <h3 className="grw-modal-head">{t('link_edit.preview')}</h3>
+            {renderLinkPreview()}
+          </div>
+        </div>
+      </ModalBody>
+      <ModalFooter>
+        { previewError && <span className='text-danger'>{previewError}</span>}
+        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={close}>
+          {t('Cancel')}
+        </button>
+        <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={save}>
+          {t('Done')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+LinkEditModal.displayName = 'LinkEditModal';

+ 4 - 2
apps/app/src/pages/[[...path]].page.tsx

@@ -75,6 +75,7 @@ const GrowiSubNavigationSwitcher = dynamic<GrowiSubNavigationSwitcherProps>(() =
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 const TemplateModal = dynamic(() => import('../components/TemplateModal').then(mod => mod.TemplateModal), { ssr: false });
+const LinkEditModal = dynamic(() => import('../components/PageEditor/LinkEditModal').then(mod => mod.LinkEditModal), { ssr: false });
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire/client/components/QuestionnaireModalManager'), { ssr: false });
 
@@ -380,6 +381,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <HandsontableModal />
       <QuestionnaireModalManager />
       <TemplateModal />
+      <LinkEditModal />
     </>
   );
 };
@@ -583,8 +585,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
     xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 

+ 2 - 2
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -101,8 +101,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
     xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 2 - 2
apps/app/src/pages/_search.page.tsx

@@ -138,8 +138,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
     xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 

+ 3 - 3
apps/app/src/pages/login/index.page.tsx

@@ -28,7 +28,7 @@ type Props = CommonProps & {
   pageWithMetaStr: string,
   isMailerSetup: boolean,
   enabledStrategies: unknown,
-  registrationWhiteList: string[],
+  registrationWhitelist: string[],
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
   isLdapSetupFailed: boolean,
@@ -60,7 +60,7 @@ const LoginPage: NextPage<Props> = (props: Props) => {
         isLdapStrategySetup={props.isLdapStrategySetup}
         isLdapSetupFailed={props.isLdapSetupFailed}
         isEmailAuthenticationEnabled={props.isEmailAuthenticationEnabled}
-        registrationWhiteList={props.registrationWhiteList}
+        registrationWhitelist={props.registrationWhitelist}
         isPasswordResetEnabled={props.isPasswordResetEnabled}
         isMailerSetup={props.isMailerSetup}
         registrationMode={props.registrationMode}
@@ -113,7 +113,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isLocalStrategySetup = passportService.isLocalStrategySetup;
   props.isLdapStrategySetup = passportService.isLdapStrategySetup;
   props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
-  props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
+  props.registrationWhitelist = configManager.getConfig('crowi', 'security:registrationWhitelist');
   props.isEmailAuthenticationEnabled = configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
   props.registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
 }

+ 6 - 6
apps/app/src/pages/me/[[...path]].page.tsx

@@ -17,7 +17,7 @@ import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhiteList, useShowPageLimitationXL, useRendererConfig,
+  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig,
 } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
@@ -38,7 +38,7 @@ type Props = CommonProps & {
   showPageLimitationXL: number,
 
   // config
-  registrationWhiteList: string[],
+  registrationWhitelist: string[],
 };
 
 const PersonalSettings = dynamic(() => import('~/components/Me/PersonalSettings'), { ssr: false });
@@ -82,7 +82,7 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
 
   useCurrentUser(props.currentUser ?? null);
 
-  useRegistrationWhiteList(props.registrationWhiteList);
+  useRegistrationWhitelist(props.registrationWhitelist);
 
   useShowPageLimitationXL(props.showPageLimitationXL);
 
@@ -143,7 +143,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
 
-  props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
+  props.registrationWhitelist = configManager.getConfig('crowi', 'security:registrationWhitelist');
 
   props.showPageLimitationXL = crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL');
 
@@ -164,8 +164,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
     xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 2 - 2
apps/app/src/pages/share/[[...path]].page.tsx

@@ -161,8 +161,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
     xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 12 - 0
apps/app/src/server/models/.eslintrc.js

@@ -0,0 +1,12 @@
+const rulesDirPlugin = require('eslint-plugin-rulesdir');
+
+rulesDirPlugin.RULES_DIR = 'src/server/models/eslint-rules-dir';
+
+module.exports = {
+  plugins: [
+    'rulesdir',
+  ],
+  rules: {
+    'rulesdir/no-populate': 'warn',
+  },
+};

+ 3 - 3
apps/app/src/server/models/config.ts

@@ -57,7 +57,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:restrictGuestMode'      : 'Deny',
 
   'security:registrationMode'      : 'Open',
-  'security:registrationWhiteList' : [],
+  'security:registrationWhitelist' : [],
 
   'security:list-policy:hideRestrictedByOwner' : false,
   'security:list-policy:hideRestrictedByGroup' : false,
@@ -142,8 +142,8 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
 
 export const defaultMarkdownConfigs: { [key: string]: any } = {
   // don't use it, but won't turn it off
-  'markdown:xss:tagWhiteList': [],
-  'markdown:xss:attrWhiteList': [],
+  'markdown:xss:tagWhitelist': [],
+  'markdown:xss:attrWhitelist': [],
 
   'markdown:rehypeSanitize:isEnabledPrevention': true,
   'markdown:rehypeSanitize:option': RehypeSanitizeOption.RECOMMENDED,

+ 27 - 0
apps/app/src/server/models/eslint-rules-dir/no-populate.js

@@ -0,0 +1,27 @@
+/**
+ * @typedef {import('eslint').Rule} Rule
+ * @typedef {import('./lib/html.js').HtmlOptions} HtmlOptions
+ */
+
+/** @type {Rule.RuleModule} */
+module.exports = {
+  meta: {
+    type: 'problem',
+  },
+  /**
+   * @property {Rule.RuleContext} context
+   * @return {Rule.RuleListener}
+   */
+  create: (context) => {
+    return {
+      CallExpression(node) {
+        if (node.callee.property && node.callee.property.name === 'populate') {
+          context.report({
+            node,
+            message: "The 'populate' method should not be called in model modules.",
+          });
+        }
+      },
+    };
+  },
+};

+ 24 - 0
apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts

@@ -0,0 +1,24 @@
+import { RuleTester } from 'eslint';
+import { test } from 'vitest';
+
+import noPopulate from '../no-populate';
+
+const ruleTester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2015,
+  },
+});
+
+test('test no-populate', () => {
+  ruleTester.run('no-populate', noPopulate, {
+    valid: [
+      { code: 'Model.find();' },
+    ],
+    invalid: [
+      {
+        code: "Model.find().populate('children');",
+        errors: [{ message: "The 'populate' method should not be called in model modules." }],
+      },
+    ],
+  });
+});

+ 1 - 1
apps/app/src/server/models/user.js

@@ -347,7 +347,7 @@ module.exports = function(crowi) {
   userSchema.statics.isEmailValid = function(email, callback) {
     validateCrowi();
 
-    const whitelist = crowi.configManager.getConfig('crowi', 'security:registrationWhiteList');
+    const whitelist = crowi.configManager.getConfig('crowi', 'security:registrationWhitelist');
 
     if (Array.isArray(whitelist) && whitelist.length > 0) {
       return whitelist.some((allowedEmail) => {

+ 13 - 13
apps/app/src/server/routes/apiv3/markdown-setting.js

@@ -26,8 +26,8 @@ const validator = {
   ],
   xssSetting: [
     body('isEnabledXss').isBoolean(),
-    body('tagWhiteList').isArray(),
-    body('attrWhiteList').isString(),
+    body('tagWhitelist').isArray(),
+    body('attrWhitelist').isString(),
   ],
 };
 
@@ -73,15 +73,15 @@ const validator = {
  *          xssOption:
  *            type: number
  *            description: number of xss option
- *          tagWhiteList:
+ *          tagWhitelist:
  *            type: array
- *            description: array of tag whiteList
+ *            description: array of tag whitelist
  *            items:
  *              type: string
  *              description: tag whitelist
- *          attrWhiteList:
+ *          attrWhitelist:
  *            type: array
- *            description: array of attr whiteList
+ *            description: array of attr whitelist
  *            items:
  *              type: string
  *              description: attr whitelist
@@ -122,8 +122,8 @@ module.exports = (crowi) => {
       isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
       isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
       xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-      tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-      attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
+      tagWhitelist: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+      attrWhitelist: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
     };
 
     return res.apiv3({ markdownParams });
@@ -235,7 +235,7 @@ module.exports = (crowi) => {
     }
 
     try {
-      JSON.parse(req.body.attrWhiteList);
+      JSON.parse(req.body.attrWhitelist);
     }
     catch (err) {
       const msg = 'Error occurred in updating xss';
@@ -246,8 +246,8 @@ module.exports = (crowi) => {
     const reqestXssParams = {
       'markdown:rehypeSanitize:isEnabledPrevention': req.body.isEnabledXss,
       'markdown:rehypeSanitize:option': req.body.xssOption,
-      'markdown:rehypeSanitize:tagNames': req.body.tagWhiteList,
-      'markdown:rehypeSanitize:attributes': req.body.attrWhiteList,
+      'markdown:rehypeSanitize:tagNames': req.body.tagWhitelist,
+      'markdown:rehypeSanitize:attributes': req.body.attrWhitelist,
     };
 
     try {
@@ -255,8 +255,8 @@ module.exports = (crowi) => {
       const xssParams = {
         isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
         xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-        tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-        attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
+        tagWhitelist: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+        attrWhitelist: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
       };
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };

+ 6 - 6
apps/app/src/server/routes/apiv3/security-setting.js

@@ -41,7 +41,7 @@ const validator = {
     body('registrationMode').isString().isIn([
       'Open', 'Restricted', 'Closed',
     ]),
-    body('registrationWhiteList').if(value => value != null).isArray().customSanitizer((value, { req }) => {
+    body('registrationWhitelist').if(value => value != null).isArray().customSanitizer((value, { req }) => {
       return value.filter(email => email !== '');
     }),
   ],
@@ -145,12 +145,12 @@ const validator = {
  *          registrationMode:
  *            type: string
  *            description: type of registrationMode
- *          registrationWhiteList:
+ *          registrationWhitelist:
  *            type: array
  *            description: array of regsitrationList
  *            items:
  *              type: string
- *              description: registration whiteList
+ *              description: registration whitelist
  *      LdapAuthSetting:
  *        type: object
  *        properties:
@@ -363,7 +363,7 @@ module.exports = (crowi) => {
       localSetting: {
         useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
-        registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        registrationWhitelist: await crowi.configManager.getConfig('crowi', 'security:registrationWhitelist'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
         isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       },
@@ -791,7 +791,7 @@ module.exports = (crowi) => {
   router.put('/local-setting', loginRequiredStrictly, adminRequired, addActivity, validator.localSetting, apiV3FormValidator, async(req, res) => {
     const requestParams = {
       'security:registrationMode': req.body.registrationMode,
-      'security:registrationWhiteList': req.body.registrationWhiteList,
+      'security:registrationWhitelist': req.body.registrationWhitelist,
       'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
       'security:passport-local:isEmailAuthenticationEnabled': req.body.isEmailAuthenticationEnabled,
     };
@@ -800,7 +800,7 @@ module.exports = (crowi) => {
 
       const localSettingParams = {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
-        registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        registrationWhitelist: await crowi.configManager.getConfig('crowi', 'security:registrationWhitelist'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
         isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       };

+ 2 - 2
apps/app/src/server/routes/page.js

@@ -155,8 +155,8 @@ module.exports = function(crowi, app) {
   const Xss = require('~/services/xss/index');
   const initializedConfig = {
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-    tagWhiteList: xssService.getTagWhiteList(),
-    attrWhiteList: xssService.getAttrWhiteList(),
+    tagWhitelist: xssService.getTagWhitelist(),
+    attrWhitelist: xssService.getAttrWhitelist(),
   };
   const xssOption = new XssOption(initializedConfig);
   const xss = new Xss(xssOption);

+ 1 - 0
apps/app/src/server/service/search.ts

@@ -25,6 +25,7 @@ const logger = loggerFactory('growi:service:search');
 const nonNullable = <T>(value: T): value is NonNullable<T> => value != null;
 
 // options for filtering xss
+// Do not change the property key name to 'whitelist" because it depends on the 'xss' library
 const filterXssOptions = {
   whiteList: {
     em: ['class'],

+ 6 - 6
apps/app/src/server/service/xss.js

@@ -20,7 +20,7 @@ class XssSerivce {
     return this.xss.process(value);
   }
 
-  getTagWhiteList() {
+  getTagWhitelist() {
     const isEnabledXssPrevention = this.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention');
     const xssOpiton = this.configManager.getConfig('markdown', 'markdown:xss:option');
 
@@ -32,8 +32,8 @@ class XssSerivce {
         case 2: // recommended
           return tags;
 
-        case 3: // custom white list
-          return this.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList');
+        case 3: // custom whitelist
+          return this.configManager.getConfig('markdown', 'markdown:xss:tagWhitelist');
 
         default:
           return [];
@@ -44,7 +44,7 @@ class XssSerivce {
     }
   }
 
-  getAttrWhiteList() {
+  getAttrWhitelist() {
     const isEnabledXssPrevention = this.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention');
     const xssOpiton = this.configManager.getConfig('markdown', 'markdown:xss:option');
 
@@ -56,8 +56,8 @@ class XssSerivce {
         case 2: // recommended
           return attrs;
 
-        case 3: // custom white list
-          return this.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList');
+        case 3: // custom whitelist
+          return this.configManager.getConfig('markdown', 'markdown:xss:attrWhitelist');
 
         default:
           return [];

+ 2 - 2
apps/app/src/services/renderer/renderer.tsx

@@ -57,8 +57,8 @@ let isInjectedCustomSanitaizeOption = false;
 
 export const injectCustomSanitizeOption = (config: RendererConfig): void => {
   if (!isInjectedCustomSanitaizeOption && config.isEnabledXssPrevention && config.xssOption === RehypeSanitizeOption.CUSTOM) {
-    commonSanitizeOption.tagNames = baseSanitizeSchema.tagNames.concat(config.tagWhiteList ?? []);
-    commonSanitizeOption.attributes = deepmerge(baseSanitizeSchema.attributes, config.attrWhiteList ?? {});
+    commonSanitizeOption.tagNames = baseSanitizeSchema.tagNames.concat(config.tagWhitelist ?? []);
+    commonSanitizeOption.attributes = deepmerge(baseSanitizeSchema.attributes, config.attrWhitelist ?? {});
     isInjectedCustomSanitaizeOption = true;
   }
 };

+ 6 - 6
apps/app/src/services/xss/index.js

@@ -10,17 +10,17 @@ class Xss {
 
     xssOption = xssOption || {}; // eslint-disable-line no-param-reassign
 
-    const tagWhiteList = xssOption.tagWhiteList || [];
-    const attrWhiteList = xssOption.attrWhiteList || [];
+    const tagWhitelist = xssOption.tagWhitelist || [];
+    const attrWhitelist = xssOption.attrWhitelist || [];
 
-    const whiteListContent = {};
+    const whitelistContent = {};
 
     // default
     const option = {
       stripIgnoreTag: true,
       stripIgnoreTagBody: false, // see https://github.com/weseek/growi/pull/505
       css: false,
-      whiteList: whiteListContent,
+      whitelist: whitelistContent,
       escapeHtml: (html) => { return html }, // resolve https://github.com/weseek/growi/issues/221
       onTag: (tag, html, options) => {
         // pass autolink
@@ -30,8 +30,8 @@ class Xss {
       },
     };
 
-    tagWhiteList.forEach((tag) => {
-      whiteListContent[tag] = attrWhiteList;
+    tagWhitelist.forEach((tag) => {
+      whitelistContent[tag] = attrWhitelist;
     });
 
     // create the XSS Filter instance

+ 9 - 8
apps/app/src/services/xss/xssOption.ts

@@ -1,31 +1,32 @@
 import { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
+
 import type { RehypeSanitizeOption } from '~/interfaces/rehype';
 
-type tagWhiteList = typeof sanitizeDefaultSchema.tagNames;
-type attrWhiteList = typeof sanitizeDefaultSchema.attributes;
+type tagWhitelist = typeof sanitizeDefaultSchema.tagNames;
+type attrWhitelist = typeof sanitizeDefaultSchema.attributes;
 
 export type XssOptionConfig = {
   isEnabledXssPrevention: boolean,
   xssOption: RehypeSanitizeOption,
-  tagWhiteList: tagWhiteList,
-  attrWhiteList: attrWhiteList,
+  tagWhitelist: tagWhitelist,
+  attrWhitelist: attrWhitelist,
 }
 
 export default class XssOption {
 
   isEnabledXssPrevention: boolean;
 
-  tagWhiteList: any[];
+  tagWhitelist: any[];
 
-  attrWhiteList: any[];
+  attrWhitelist: any[];
 
   constructor(config: XssOptionConfig) {
     const recommendedWhitelist = require('~/services/xss/recommended-whitelist');
     const initializedConfig: Partial<XssOptionConfig> = (config != null) ? config : {};
 
     this.isEnabledXssPrevention = initializedConfig.isEnabledXssPrevention || true;
-    this.tagWhiteList = initializedConfig.tagWhiteList || recommendedWhitelist.tags;
-    this.attrWhiteList = initializedConfig.attrWhiteList || recommendedWhitelist.attrs;
+    this.tagWhitelist = initializedConfig.tagWhitelist || recommendedWhitelist.tags;
+    this.attrWhitelist = initializedConfig.attrWhitelist || recommendedWhitelist.attrs;
   }
 
 }

+ 2 - 2
apps/app/src/stores/context.tsx

@@ -68,8 +68,8 @@ export const useDisableLinkSharing = (initialData?: Nullable<boolean>): SWRRespo
   return useContextSWR<Nullable<boolean>, Error>('disableLinkSharing', initialData);
 };
 
-export const useRegistrationWhiteList = (initialData?: Nullable<string[]>): SWRResponse<Nullable<string[]>, Error> => {
-  return useContextSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
+export const useRegistrationWhitelist = (initialData?: Nullable<string[]>): SWRResponse<Nullable<string[]>, Error> => {
+  return useContextSWR<Nullable<string[]>, Error>('registrationWhitelist', initialData);
 };
 
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {

+ 30 - 0
apps/app/src/stores/modal.tsx

@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
 
 import { SWRResponse } from 'swr';
 
+import Linker from '~/client/models/Linker';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
@@ -650,3 +651,32 @@ export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & Te
     },
   });
 };
+
+/*
+ * LinkEditModal
+ */
+type LinkEditModalStatus = {
+  isOpened: boolean,
+  defaultMarkdownLink?: Linker,
+  onSave?: (linkText: string) => void
+}
+
+type LinkEditModalUtils = {
+  open(defaultMarkdownLink: Linker, onSave: (linkText: string) => void): void,
+  close(): void,
+}
+
+export const useLinkEditModal = (): SWRResponse<LinkEditModalStatus, Error> & LinkEditModalUtils => {
+
+  const initialStatus: LinkEditModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<LinkEditModalStatus, Error>('linkEditModal', undefined, { fallbackData: initialStatus });
+
+  return Object.assign(swrResponse, {
+    open: (defaultMarkdownLink: Linker, onSave: (linkText: string) => void) => {
+      swrResponse.mutate({ isOpened: true, defaultMarkdownLink, onSave });
+    },
+    close: () => {
+      swrResponse.mutate({ isOpened: false });
+    },
+  });
+};

+ 7 - 0
apps/app/vitest.config.unit.ts

@@ -0,0 +1,7 @@
+import { defineProject } from 'vitest/config';
+
+export default defineProject({
+  test: {
+    environment: 'node',
+  },
+});

+ 8 - 4
package.json

@@ -60,15 +60,17 @@
     "@swc/jest": "^0.2.24",
     "@testing-library/cypress": "^8.0.2",
     "@types/css-modules": "^1.0.2",
+    "@types/eslint": "^8.37.0",
+    "@types/estree": "^1.0.1",
     "@types/jest": "^26.0.22",
     "@types/node": "^17.0.43",
     "@types/rewire": "^2.5.28",
-    "@typescript-eslint/eslint-plugin": "^5.54.0",
-    "@typescript-eslint/parser": "^5.54.0",
+    "@typescript-eslint/eslint-plugin": "^5.59.7",
+    "@typescript-eslint/parser": "^5.59.7",
     "@vitejs/plugin-react": "^3.1.0",
     "cypress": "^12.0.1",
     "cypress-wait-until": "^1.7.2",
-    "eslint": "^8.35.0",
+    "eslint": "^8.41.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.1",
     "eslint-import-resolver-typescript": "^3.2.5",
@@ -76,6 +78,7 @@
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
+    "eslint-plugin-rulesdir": "^0.2.2",
     "glob": "^8.1.0",
     "jest": "^28.1.3",
     "jest-date-mock": "^1.0.8",
@@ -95,7 +98,8 @@
     "typescript": "~4.9",
     "unplugin-swc": "^1.3.2",
     "vite": "^4.2.2",
-    "vite-plugin-dts": "^2.0.0-beta.0"
+    "vite-plugin-dts": "^2.0.0-beta.0",
+    "vitest": "^0.31.1"
   },
   "engines": {
     "node": "^16 || ^18",

+ 4 - 0
vitest.workspace.ts

@@ -0,0 +1,4 @@
+export default [
+  'apps/*/vitest.config.{e2e,unit}.ts',
+  'packages/*/vitest.config.{e2e,unit}.ts',
+];

Разлика између датотеке није приказан због своје велике величине
+ 494 - 64
yarn.lock


Неке датотеке нису приказане због велике количине промена