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

Merge branch 'fix/putback' of https://github.com/weseek/growi into fix/putback

Taichi Masuyama 3 лет назад
Родитель
Сommit
d9aefb8b77

+ 0 - 2
packages/app/public/static/locales/en_US/admin.json

@@ -383,8 +383,6 @@
     "bucket_name": "Bucket name",
     "custom_endpoint": "Custom endpoint",
     "custom_endpoint_change": "Input the URL of the endpoint of an object storage service like MinIO that has a S3-compatible API.  Amazon S3 is used if empty.",
-    "plugin_settings": "Plugin settings",
-    "enable_plugin_loading": "Enable plugin loading",
     "load_plugins": "Load plugins",
     "enable": "Enable",
     "disable": "Disable",

+ 0 - 2
packages/app/public/static/locales/ja_JP/admin.json

@@ -391,8 +391,6 @@
     "bucket_name": "バケット名",
     "custom_endpoint": "カスタムエンドポイント",
     "custom_endpoint_change": "MinIOなど、S3互換APIを持つ他のオブジェクトストレージサービスを使用する場合のみ、そのエンドポイントのURLを入力してください。空欄の場合は、Amazon S3を使用します。",
-    "plugin_settings": "プラグイン設定",
-    "enable_plugin_loading": "プラグインの読み込みを有効にします。",
     "load_plugins": "プラグインを読み込む",
     "enable": "有効",
     "disable": "無効",

+ 0 - 2
packages/app/public/static/locales/zh_CN/admin.json

@@ -391,8 +391,6 @@
     "bucket_name": "Bucket name",
     "custom_endpoint": "Custom endpoint",
     "custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
-    "plugin_settings": "插件设置",
-    "enable_plugin_loading": "启用插件加载",
     "load_plugins": "加载插件",
     "enable": "启用",
     "disable": "停用",

+ 0 - 20
packages/app/src/client/services/AdminAppContainer.js

@@ -315,13 +315,6 @@ export default class AdminAppContainer extends Container {
     this.setState({ gcsReferenceFileWithRelayMode });
   }
 
-  /**
-   * Change secret key
-   */
-  changeIsEnabledPlugins(isEnabledPlugins) {
-    this.setState({ isEnabledPlugins });
-  }
-
   /**
    * Update app setting
    * @memberOf AdminAppContainer
@@ -441,19 +434,6 @@ export default class AdminAppContainer extends Container {
     return this.setState(responseParams);
   }
 
-  /**
-   * Update plugin setting
-   * @memberOf AdminAppContainer
-   * @return {Array} Appearance
-   */
-  async updatePluginSettingHandler() {
-    const response = await apiv3Put('/app-settings/plugin-setting', {
-      isEnabledPlugins: this.state.isEnabledPlugins,
-    });
-    const { pluginSettingParams } = response.data;
-    return pluginSettingParams;
-  }
-
   /**
    * Start v5 page migration
    * @memberOf AdminAppContainer

+ 0 - 8
packages/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -14,7 +14,6 @@ import AppSetting from './AppSetting';
 import FileUploadSetting from './FileUploadSetting';
 import MailSetting from './MailSetting';
 import { MaintenanceMode } from './MaintenanceMode';
-import PluginSetting from './PluginSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import V5PageMigration from './V5PageMigration';
 
@@ -108,13 +107,6 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
       </div>
 
-      <div className="row mt-5">
-        <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('admin:app_setting.plugin_settings')}</h2>
-          <PluginSetting />
-        </div>
-      </div>
-
       <div className="row">
         <div className="col-lg-12">
           <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>

+ 0 - 66
packages/app/src/components/Admin/App/PluginSetting.tsx

@@ -1,66 +0,0 @@
-import React, { useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import loggerFactory from '~/utils/logger';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
-const logger = loggerFactory('growi:app:pluginSetting');
-
-type Props = {
-  adminAppContainer: AdminAppContainer,
-}
-
-const PluginSetting = (props: Props) => {
-  const { t } = useTranslation();
-  const { adminAppContainer } = props;
-
-
-  const submitHandler = useCallback(async() => {
-    try {
-      await adminAppContainer.updatePluginSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.plugin_settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }, [adminAppContainer, t]);
-
-  return (
-    <>
-      <p className="card well">{t('admin:app_setting.enable_plugin_loading')}</p>
-
-      <div className="row form-group mb-5">
-        <div className="offset-3 col-6 text-left">
-          <div className="custom-control custom-checkbox custom-checkbox-success">
-            <input
-              id="isEnabledPlugins"
-              className="custom-control-input"
-              type="checkbox"
-              checked={adminAppContainer.state.isEnabledPlugins}
-              onChange={(e) => {
-                adminAppContainer.changeIsEnabledPlugins(e.target.checked);
-              }}
-            />
-            <label className="custom-control-label" htmlFor="isEnabledPlugins">{t('admin:app_setting.load_plugins')}</label>
-          </div>
-        </div>
-      </div>
-
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-    </>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PluginSettingWrapper = withUnstatedContainers(PluginSetting, [AdminAppContainer]);
-
-export default PluginSettingWrapper;

+ 0 - 3
packages/app/src/interfaces/activity.ts

@@ -74,7 +74,6 @@ const ACTION_ADMIN_MAIL_SMTP_UPDATE = 'ADMIN_MAIL_SMTP_UPDATE';
 const ACTION_ADMIN_MAIL_SES_UPDATE = 'ADMIN_MAIL_SES_UPDATE';
 const ACTION_ADMIN_MAIL_TEST_SUBMIT = 'ADMIN_MAIL_TEST_SUBMIT';
 const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE = 'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
-const ACTION_ADMIN_PLUGIN_UPDATE = 'ADMIN_PLUGIN_UPDATE';
 const ACTION_ADMIN_MAINTENANCEMODE_ENABLED = 'ADMIN_MAINTENANCEMODE_ENABLED';
 const ACTION_ADMIN_MAINTENANCEMODE_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
 const ACTION_ADMIN_SECURITY_SETTINGS_UPDATE = 'ADMIN_SECURITY_SETTINGS_UPDATE';
@@ -249,7 +248,6 @@ export const SupportedAction = {
   ACTION_ADMIN_MAIL_SES_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
-  ACTION_ADMIN_PLUGIN_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
@@ -432,7 +430,6 @@ export const LargeActionGroup = {
   ACTION_ADMIN_MAIL_SES_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
-  ACTION_ADMIN_PLUGIN_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,

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

@@ -109,8 +109,6 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'mail:smtpUser'     : undefined,
   'mail:smtpPassword' : undefined,
 
-  'plugin:isEnabledPlugins' : true,
-
   'customize:css' : undefined,
   'customize:script' : undefined,
   'customize:noscript' : undefined,

+ 0 - 48
packages/app/src/server/routes/apiv3/app-settings.js

@@ -198,9 +198,6 @@ module.exports = (crowi) => {
       body('s3SecretAccessKey').trim(),
       body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
     ],
-    pluginSetting: [
-      body('isEnabledPlugins').isBoolean(),
-    ],
     maintenanceMode: [
       body('flag').isBoolean(),
     ],
@@ -673,51 +670,6 @@ module.exports = (crowi) => {
 
   });
 
-  /**
-   * @swagger
-   *
-   *    /app-settings/plugin-setting:
-   *      put:
-   *        tags: [AppSettings]
-   *        operationId: updateAppSettingPluginSetting
-   *        summary: /app-settings/plugin-setting
-   *        description: Update plugin setting
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/PluginSettingParams'
-   *        responses:
-   *          200:
-   *            description: Succeeded to update plugin setting
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/PluginSettingParams'
-   */
-  router.put('/plugin-setting', loginRequiredStrictly, adminRequired, addActivity, validator.pluginSetting, apiV3FormValidator, async(req, res) => {
-    const requestPluginSettingParams = {
-      'plugin:isEnabledPlugins': req.body.isEnabledPlugins,
-    };
-
-    try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestPluginSettingParams);
-      const pluginSettingParams = {
-        isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
-      };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_PLUGIN_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ pluginSettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating plugin setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-pluginSetting-failed'));
-    }
-
-  });
-
   router.post('/v5-schema-migration', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
     const isMaintenanceMode = crowi.appService.isMaintenanceMode();
     if (!isMaintenanceMode) {

+ 121 - 93
packages/app/src/server/service/plugin.ts

@@ -9,7 +9,7 @@ import streamToPromise from 'stream-to-promise';
 import unzipper from 'unzipper';
 
 import {
-  GrowiPlugin, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta,
+  GrowiPlugin, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta, GrowiPluginMeta,
 } from '~/interfaces/plugin';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
@@ -42,19 +42,22 @@ export interface IPluginService {
 
 export class PluginService implements IPluginService {
 
+  /*
+  * Downloading a non-existent repository to the file system
+  */
   async downloadNotExistPluginRepositories(): Promise<void> {
     try {
-      // check all growi plugin documents
+      // find all growi plugin documents
       const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
       const growiPlugins = await GrowiPlugin.find({});
+
+      // if not exists repository in file system, download latest plugin repository
       for await (const growiPlugin of growiPlugins) {
         const pluginPath = path.join(pluginStoringPath, growiPlugin.installedPath);
         if (fs.existsSync(pluginPath)) {
-          // if exists repository, do nothing
           continue;
         }
         else {
-          // if not exists repository, download latest plugin repository
           // TODO: imprv Document version and repository version possibly different.
           const ghUrl = new URL(growiPlugin.origin.url);
           const ghPathname = ghUrl.pathname;
@@ -68,8 +71,24 @@ export class PluginService implements IPluginService {
           const ghOrganizationName = match[1];
           const ghReposName = match[2];
 
-          // download github repository to local file system
-          await this.downloadPluginRepository(ghOrganizationName, ghReposName, ghBranch);
+          const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
+          const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
+          const unzippedPath = pluginStoringPath;
+          const unzippedReposPath = path.join(pluginStoringPath, `${ghReposName}-${ghBranch}`);
+
+          try {
+            // download github repository to local file system
+            await this.download(requestUrl, zipFilePath);
+            await this.unzip(zipFilePath, unzippedPath);
+            fs.renameSync(unzippedReposPath, pluginPath);
+          }
+          catch (err) {
+            // clean up, documents are not operated
+            if (fs.existsSync(unzippedReposPath)) await fs.promises.rm(unzippedReposPath, { recursive: true });
+            if (fs.existsSync(pluginPath)) await fs.promises.rm(pluginPath, { recursive: true });
+            logger.error(err);
+          }
+
           continue;
         }
       }
@@ -79,36 +98,71 @@ export class PluginService implements IPluginService {
     }
   }
 
+  /*
+  * Install a plugin from URL and save it in the DB and file system.
+  */
   async install(origin: GrowiPluginOrigin): Promise<string> {
+    const ghUrl = new URL(origin.url);
+    const ghPathname = ghUrl.pathname;
+    // TODO: Branch names can be specified.
+    const ghBranch = 'main';
+
+    const match = ghPathname.match(githubReposIdPattern);
+    if (ghUrl.hostname !== 'github.com' || match == null) {
+      throw new Error('GitHub repository URL is invalid.');
+    }
+
+    const ghOrganizationName = match[1];
+    const ghReposName = match[2];
+    const installedPath = `${ghOrganizationName}/${ghReposName}`;
+
+    const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
+    const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
+    const unzippedPath = pluginStoringPath;
+    const unzippedReposPath = path.join(pluginStoringPath, `${ghReposName}-${ghBranch}`);
+    const temporaryReposPath = path.join(pluginStoringPath, ghReposName);
+    const reposStoringPath = path.join(pluginStoringPath, `${installedPath}`);
+
+
+    let plugins: GrowiPlugin<GrowiPluginMeta>[];
+
     try {
-    // download
-      const ghUrl = new URL(origin.url);
-      const ghPathname = ghUrl.pathname;
-      // TODO: Branch names can be specified.
-      const ghBranch = 'main';
-
-      const match = ghPathname.match(githubReposIdPattern);
-      if (ghUrl.hostname !== 'github.com' || match == null) {
-        throw new Error('GitHub repository URL is invalid.');
-      }
+      // download github repository to file system's temporary path
+      await this.download(requestUrl, zipFilePath);
+      await this.unzip(zipFilePath, unzippedPath);
+      fs.renameSync(unzippedReposPath, temporaryReposPath);
 
-      const ghOrganizationName = match[1];
-      const ghReposName = match[2];
-      const installedPath = `${ghOrganizationName}/${ghReposName}`;
+      // detect plugins
+      plugins = await PluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
 
-      // download github repository to local file system
-      await this.downloadPluginRepository(ghOrganizationName, ghReposName, ghBranch);
+      // remove the old repository from the storing path
+      if (fs.existsSync(reposStoringPath)) await fs.promises.rm(reposStoringPath, { recursive: true });
+
+      // move new repository from temporary path to storing path.
+      fs.renameSync(temporaryReposPath, reposStoringPath);
+    }
+    catch (err) {
+      // clean up
+      if (fs.existsSync(zipFilePath)) await fs.promises.rm(zipFilePath);
+      if (fs.existsSync(unzippedReposPath)) await fs.promises.rm(unzippedReposPath, { recursive: true });
+      if (fs.existsSync(temporaryReposPath)) await fs.promises.rm(temporaryReposPath, { recursive: true });
+      logger.error(err);
+      throw err;
+    }
 
-      // delete old document
+    try {
+      // delete plugin documents if these exist
       await this.deleteOldPluginDocument(installedPath);
 
-      // save plugin metadata
-      const plugins = await PluginService.detectPlugins(origin, installedPath);
+      // save new plugins metadata
       await this.savePluginMetaData(plugins);
 
       return plugins[0].meta.name;
     }
     catch (err) {
+      // clean up
+      if (fs.existsSync(reposStoringPath)) await fs.promises.rm(reposStoringPath, { recursive: true });
+      await this.deleteOldPluginDocument(installedPath);
       logger.error(err);
       throw err;
     }
@@ -119,72 +173,46 @@ export class PluginService implements IPluginService {
     await GrowiPlugin.deleteMany({ installedPath: path });
   }
 
-  private async downloadPluginRepository(ghOrganizationName: string, ghReposName: string, ghBranch: string): Promise<void> {
-
-    const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
-    const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
-    const unzippedPath = path.join(pluginStoringPath, ghOrganizationName);
-
-    const downloadFile = async(requestUrl: string, filePath: string) => {
-      return new Promise<void>((resolve, rejects) => {
-        axios({
-          method: 'GET',
-          url: requestUrl,
-          responseType: 'stream',
-        })
-          .then((res) => {
-            if (res.status === 200) {
-              const file = fs.createWriteStream(filePath);
-              res.data.pipe(file)
-                .on('close', () => file.close())
-                .on('finish', () => {
-                  return resolve();
-                });
-            }
-            else {
-              rejects(res.status);
-            }
-          }).catch((err) => {
-            logger.error(err);
-            // eslint-disable-next-line prefer-promise-reject-errors
-            rejects('Filed to download file.');
-          });
-      });
-    };
-
-    const unzip = async(zipFilePath: fs.PathLike, unzippedPath: fs.PathLike) => {
-      try {
-        const stream = fs.createReadStream(zipFilePath);
-        const unzipStream = stream.pipe(unzipper.Extract({ path: unzippedPath }));
-        const deleteZipFile = (path: fs.PathLike) => fs.unlinkSync(path);
-
-        await streamToPromise(unzipStream);
-        deleteZipFile(zipFilePath);
-      }
-      catch (err) {
-        logger.error(err);
-        throw new Error('Filed to unzip.');
-      }
-    };
-
-    const renamePath = async(oldPath: fs.PathLike, newPath: fs.PathLike) => {
-      try {
-        // if repository already exists, delete old repository before rename path
-        if (fs.existsSync(newPath)) await fs.promises.rm(newPath, { recursive: true });
-        // rename repository
-        fs.renameSync(oldPath, newPath);
-      }
-      catch (err) {
-        logger.error(err);
-        throw new Error('Filed to rename path.');
-      }
-    };
+  // !! DO NOT USE WHERE NOT SSRF GUARDED !! -- 2022.12.26 ryoji-s
+  private async download(requestUrl: string, filePath: string): Promise<void> {
+    return new Promise<void>((resolve, rejects) => {
+      axios({
+        method: 'GET',
+        url: requestUrl,
+        responseType: 'stream',
+      })
+        .then((res) => {
+          if (res.status === 200) {
+            const file = fs.createWriteStream(filePath);
+            res.data.pipe(file)
+              .on('close', () => file.close())
+              .on('finish', () => {
+                return resolve();
+              });
+          }
+          else {
+            rejects(res.status);
+          }
+        }).catch((err) => {
+          logger.error(err);
+          // eslint-disable-next-line prefer-promise-reject-errors
+          rejects('Filed to download file.');
+        });
+    });
+  }
 
-    await downloadFile(requestUrl, zipFilePath);
-    await unzip(zipFilePath, unzippedPath);
-    await renamePath(`${unzippedPath}/${ghReposName}-${ghBranch}`, `${unzippedPath}/${ghReposName}`);
+  private async unzip(zipFilePath: fs.PathLike, unzippedPath: fs.PathLike): Promise<void> {
+    try {
+      const stream = fs.createReadStream(zipFilePath);
+      const unzipStream = stream.pipe(unzipper.Extract({ path: unzippedPath }));
 
-    return;
+      await streamToPromise(unzipStream);
+      await fs.promises.rm(zipFilePath);
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error('Filed to unzip.');
+    }
   }
 
   private async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
@@ -192,9 +220,9 @@ export class PluginService implements IPluginService {
     await GrowiPlugin.insertMany(plugins);
   }
 
-  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  private static async detectPlugins(origin: GrowiPluginOrigin, installedPath: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
-    const packageJsonPath = path.resolve(pluginStoringPath, installedPath, 'package.json');
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
+  private static async detectPlugins(origin: GrowiPluginOrigin, ghOrganizationName: string, ghReposName: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
+    const packageJsonPath = path.resolve(pluginStoringPath, ghReposName, 'package.json');
     const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
 
     const { growiPlugin } = packageJson;
@@ -211,7 +239,7 @@ export class PluginService implements IPluginService {
     if (growiPlugin.isMonorepo && growiPlugin.packages != null) {
       const plugins = await Promise.all(
         growiPlugin.packages.map(async(subPackagePath) => {
-          const subPackageInstalledPath = path.join(installedPath, subPackagePath);
+          const subPackageInstalledPath = path.join(ghReposName, subPackagePath);
           return this.detectPlugins(origin, subPackageInstalledPath, packageJson);
         }),
       );
@@ -223,7 +251,7 @@ export class PluginService implements IPluginService {
     }
     const plugin = {
       isEnabled: true,
-      installedPath,
+      installedPath: `${ghOrganizationName}/${ghReposName}`,
       origin,
       meta: {
         name: growiPlugin.name ?? packageName,

+ 0 - 4
packages/app/src/server/views/layout/layout.html

@@ -30,9 +30,6 @@
 
   <script src="{{ webpack_asset('js/vendors.js') }}" defer></script>
   <script src="{{ webpack_asset('js/commons.js') }}" defer></script>
-  {% if getConfig('crowi', 'plugin:isEnabledPlugins') %}
-  <script src="{{ webpack_asset('js/plugin.js') }}" defer></script>
-  {% endif %}
   {% block html_head_loading_legacy %}
     <script src="{{ webpack_asset('js/legacy.js') }}" defer></script>
   {% endblock %}
@@ -74,7 +71,6 @@
 
 <body
   class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClasses|join(' ') }}"
-  data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   {% block html_base_attr %}{% endblock %}
  >