Przeglądaj źródła

Merge pull request #11 from weseek/support/create-admin-plugin-page

support: Create Admin PluginsExtensionPage
ryoji-s 3 lat temu
rodzic
commit
0a5924264d

BIN
packages/app/master.zip


+ 1 - 1
packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 

+ 21 - 0
packages/app/src/components/Admin/Common/AdminInstallButtonRow.tsx

@@ -0,0 +1,21 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+type Props = {
+  onClick: () => void,
+  disabled: boolean,
+
+}
+
+export const AdminInstallButtonRow = (props: Props): JSX.Element => {
+  // TODO: const { t } = useTranslation('admin');
+
+  return (
+    <div className="row my-3">
+      <div className="mx-auto">
+        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled}>Install</button>
+      </div>
+    </div>
+  );
+};

+ 3 - 0
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -38,6 +38,7 @@ const AdminNavigation = (props) => {
         { t('full_text_search_management.full_text_search_management') }</>;
       // TODO: Consider where to place the "AuditLog"
       case 'audit-log':                return <><i className="icon-fw icon-feed"></i>            { t('audit_log_management.audit_log')}</>;
+      case 'plugins-extention':        return <><i className="icon-fw icon-plugin"></i>          { t('plugins-extention.title')}</>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('wiki_management_home_page') }</>;
       /* eslint-enable no-multi-spaces */
@@ -94,6 +95,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
+        <MenuLink menu="plugins-extention"    isListGroupItems isActive={isActiveMenu('/plugins-extention')} />
         {/* {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
             <a
@@ -142,6 +144,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
+            {isActiveMenu('/plugins-extention') && <MenuLabel menu="plugins-extention" />}
             {/* eslint-enable no-multi-spaces */}
           </span>
         </button>

+ 18 - 0
packages/app/src/components/Admin/PluginsExtension/PluginCard.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+export const PluginCard = (): JSX.Element => {
+  return (
+    <div>
+      <div>
+        <strong>Plugin Name</strong>
+        <small>Discription</small>
+        <span>version</span>
+        <span>tags</span>
+      </div>
+      <div>
+        <span>URL</span>
+        <span>DeleteButton</span>
+      </div>
+    </div>
+  );
+};

+ 91 - 0
packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx

@@ -0,0 +1,91 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import AdminInstallButtonRow from '../Common/AdminUpdateButtonRow';
+// TODO: error notification (toast, loggerFactory)
+// TODO: i18n
+
+export const PluginInstallerForm = (): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      'pluginInstallerForm[url]': { value: url },
+      'pluginInstallerForm[ghBranch]': { value: ghBranch },
+      'pluginInstallerForm[ghTag]': { value: ghTag },
+    } = formData;
+
+    const pluginInstallerForm = {
+      url,
+      ghBranch,
+      ghTag,
+    };
+
+    try {
+      await apiv3Post('/plugins-extention', { pluginInstallerForm });
+      toastSuccess(t('toaster.update_successed', { target: t('app_settings') }));
+    }
+    catch (err) {
+      toastError(err);
+      // logger.error(err);
+    }
+  }, [t]);
+
+  return (
+    <form role="form" onSubmit={submitHandler}>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">GitHub repository URL</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            // defaultValue={adminAppContainer.state.title || ''}
+            name="pluginInstallerForm[url]"
+            placeholder="https://github.com/weseek/growi-plugins/vibrant-dark-ui"
+            required
+          />
+          <p className="form-text text-muted">Install the plugin in GROWI: Enter the URL of the plugin repository and press the Update.</p>
+          {/* <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p> */}
+        </div>
+      </div>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">branch</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="pluginInstallerForm[ghBranch]"
+            placeholder="main"
+          />
+          <p className="form-text text-muted">branch name</p>
+        </div>
+      </div>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">tag</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="pluginInstallerForm[ghTag]"
+            placeholder="tags"
+          />
+          <p className="form-text text-muted">tag name</p>
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="submit" className="btn btn-primary" >Install</button>
+        </div>
+      </div>
+    </form>
+  );
+};

+ 22 - 0
packages/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+
+import { PluginCard } from './PluginCard';
+import { PluginInstallerForm } from './PluginInstallerForm';
+
+// TODO: i18n
+
+export const PluginsExtensionPageContents = (): JSX.Element => {
+  return (
+    <div>
+      <div className="row">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">Plugins Installer</h2>
+          <PluginInstallerForm />
+        </div>
+      </div>
+      <div>
+        <PluginCard />
+      </div>
+    </div>
+  );
+};

+ 7 - 1
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -63,6 +63,8 @@ const AdminLayout = dynamic(() => import('../../components/Layout/AdminLayout'),
 // named export
 const UserGroupPage = dynamic(() => import('../../components/Admin/UserGroup/UserGroupPage').then(mod => mod.UserGroupPage), { ssr: false });
 const AuditLogManagement = dynamic(() => import('../../components/Admin/AuditLogManagement').then(mod => mod.AuditLogManagement), { ssr: false });
+const PluginsExtensionPageContents = dynamic(() => import('../../components/Admin/PluginsExtension/PluginsExtensionPageContents')
+  .then(mod => mod.PluginsExtensionPageContents), { ssr: false });
 
 type Props = CommonProps & {
   currentUser: any,
@@ -178,6 +180,10 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       title: t('audit_log_management.audit_log'),
       component: <AuditLogManagement />,
     },
+    'plugins-extention': {
+      title: t('plugins-extention.title'),
+      component: <PluginsExtensionPageContents />,
+    },
   };
 
   const getTargetPageToRender = (pagesMap, keys): {title: string, component: JSX.Element} => {
@@ -284,7 +290,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.nodeVersion = crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : null;
   props.npmVersion = crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : null;
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
-  props.installedPlugins = crowi.pluginService.listPlugins();
+  props.installedPlugins = crowi.pluginService.listPlugins(crowi.rootDir);
   props.envVars = await ConfigLoader.getEnvVarsForDisplay(true);
   props.isAclEnabled = aclService.isAclEnabled();
 

+ 10 - 0
packages/app/src/server/crowi/index.js

@@ -26,6 +26,7 @@ import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
+import { PluginService } from '../service/plugin';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
@@ -67,6 +68,7 @@ function Crowi() {
   this.importService = null;
   this.pluginService = null;
   this.searchService = null;
+  this.pluginService = null;
   this.socketIoService = null;
   this.pageService = null;
   this.syncPageStatusService = null;
@@ -120,6 +122,7 @@ Crowi.prototype.init = async function() {
     this.scanRuntimeVersions(),
     this.setupPassport(),
     this.setupSearcher(),
+    this.setupPluginer(),
     this.setupMailer(),
     this.setupSlackIntegrationService(),
     this.setUpFileUpload(),
@@ -370,6 +373,13 @@ Crowi.prototype.setupSearcher = async function() {
   this.searchService = new SearchService(this);
 };
 
+/**
+ * setup PluginService
+ */
+Crowi.prototype.setupPluginer = async function() {
+  this.pluginService = new PluginService(this);
+};
+
 Crowi.prototype.setupMailer = async function() {
   const MailService = require('~/server/service/mail');
   this.mailService = new MailService(this);

+ 2 - 0
packages/app/src/server/routes/apiv3/import.js

@@ -214,6 +214,8 @@ module.exports = (crowi) => {
     // TODO: add express validator
     const { fileName, collections, optionsMap } = req.body;
 
+    console.log('fileName', fileName);
+
     // pages collection can only be imported by upsert if isV5Compatible is true
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isImportPagesCollection = collections.includes('pages');

+ 2 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -99,6 +99,8 @@ module.exports = (crowi, app, isInstalled) => {
     userActivation.validateCompleteRegistration,
     userActivation.completeRegistrationAction(crowi));
 
+  router.use('/plugins-extention', require('./plugins-extention')(crowi));
+
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 

+ 30 - 0
packages/app/src/server/routes/apiv3/plugins-extention.ts

@@ -0,0 +1,30 @@
+import express, { Request } from 'express';
+
+import Crowi from '../../crowi';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+
+type PluginInstallerFormRequest = Request & { form: any };
+
+module.exports = (crowi: Crowi) => {
+  const router = express.Router();
+  const { pluginService } = crowi;
+
+  router.post('/', async(req: PluginInstallerFormRequest, res: ApiV3Response) => {
+    if (pluginService == null) {
+      return res.apiv3Err(400);
+    }
+
+    try {
+      await pluginService.install(crowi, req.body.pluginInstallerForm);
+      return res.apiv3({});
+    }
+    catch (err) {
+      // TODO: error handling
+      return res.apiv3Err(err, 400);
+    }
+  });
+
+  return router;
+};

+ 116 - 3
packages/app/src/server/service/plugin.ts

@@ -1,18 +1,53 @@
+
+import { execSync } from 'child_process';
+import fs from 'fs';
 import path from 'path';
 
+import streamToPromise from 'stream-to-promise';
+import unzipper from 'unzipper';
+
 import { GrowiPlugin, GrowiPluginOrigin } from '~/interfaces/plugin';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-const logger = loggerFactory('growi:service:PluginService');
 
+// eslint-disable-next-line import/no-cycle
+import Crowi from '../crowi';
+
+
+const logger = loggerFactory('growi:plugins:plugin-utils');
 
 const pluginStoringPath = resolveFromRoot('tmp/plugins');
 
+
 export class PluginService {
 
-  static async install(origin: GrowiPluginOrigin): Promise<void> {
-    // TODO: download
+  crowi: any;
+
+  growiBridgeService: any;
+
+  baseDir: any;
+
+  getFile:any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.growiBridgeService = crowi.growiBridgeService;
+    this.baseDir = path.join(crowi.tmpDir, 'plugins');
+    this.getFile = this.growiBridgeService.getFile.bind(this);
+  }
+
+  async install(crowi: Crowi, origin: GrowiPluginOrigin): Promise<void> {
+    // download
+    const ghUrl = origin.url;
+    const downloadDir = path.join(process.cwd(), 'tmp/plugins/');
+    try {
+      await this.downloadZipFile(`${ghUrl}/archive/refs/heads/master.zip`, downloadDir);
+    }
+    catch (err) {
+      // TODO: error handling
+    }
+
     // TODO: detect plugins
     // TODO: save documents
     return;
@@ -70,4 +105,82 @@ export class PluginService {
     return [];
   }
 
+  sleep(waitMsec) {
+    const startMsec = new Date();
+
+    while (new Date() - startMsec < waitMsec);
+  }
+
+  async downloadZipFile(ghUrl: string, filePath:string): Promise<void> {
+
+    console.log(`rm ${filePath}master.zip`);
+
+    const stdout1 = execSync(`wget ${ghUrl} -O ${filePath}master.zip`);
+    console.log(`wget ${ghUrl} -O ${filePath}master.zip`);
+    console.log(`unzip ${filePath}master.zip -d ${filePath}`);
+    this.sleep(5000);
+    const stdout2 = execSync(`unzip ${filePath}master.zip -d ${filePath}`);
+    console.log(`unzip ${filePath}master.zip -d ${filePath}`);
+    const stdout3 = execSync(`rm ${filePath}master.zip`);
+
+    // try {
+    //   const zipFile = await this.getFile('master.zip');
+
+    //   // await this.unzip('/workspace/growi/packages/app/tmp/plugins/master.zip');
+
+    // }
+    // catch (err) {
+    //   console.log(err);
+    // }
+    return;
+  }
+
+  /**
+   * extract a zip file
+   *
+   * @memberOf ImportService
+   * @param {string} zipFile absolute path to zip file
+   * @return {Array.<string>} array of absolute paths to extracted files
+   */
+  async unzip(zipFile) {
+    // const stream = fs.createReadStream(zipFile).pipe(unzipper.Extract({ path: '/workspace/growi/packages/app/tmp/plugins/master' }));
+    // try {
+    //   await streamToPromise(stream);
+    // }
+    // catch (err) {
+    //   console.log('err', err);
+    // }
+    const readStream = fs.createReadStream(zipFile);
+    const unzipStream = readStream.pipe(unzipper.Parse());
+    const files: any = [];
+
+
+    unzipStream.on('entry', async(entry) => {
+      const fileName = entry.path;
+      // https://regex101.com/r/mD4eZs/6
+      // prevent from unexpecting attack doing unzip file (path traversal attack)
+      // FOR EXAMPLE
+      // ../../src/server/views/admin/markdown.html
+      if (fileName.match(/(\.\.\/|\.\.\\)/)) {
+        logger.error('File path is not appropriate.', fileName);
+        return;
+      }
+
+      if (fileName === this.growiBridgeService.getMetaFileName()) {
+        // skip meta.json
+        entry.autodrain();
+      }
+      else {
+        const jsonFile = path.join(this.baseDir, fileName);
+        const writeStream = fs.createWriteStream(jsonFile, { encoding: this.growiBridgeService.getEncoding() });
+        entry.pipe(writeStream);
+        files.push(jsonFile);
+      }
+    });
+
+    await streamToPromise(unzipStream);
+
+    return files;
+  }
+
 }

BIN
packages/master.zip


BIN
packages/master.zip.1