import fs from 'fs'; import path from 'path'; import mongoose from 'mongoose'; import request from 'superagent'; import unzipper from 'unzipper'; import type { GrowiPlugin, GrowiPluginOrigin } from '~/interfaces/plugin'; import loggerFactory from '~/utils/logger'; import { resolveFromRoot } from '~/utils/project-dir-utils'; const logger = loggerFactory('growi:plugins:plugin-utils'); const pluginStoringPath = resolveFromRoot('tmp/plugins'); // https://regex101.com/r/fK2rV3/1 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/); export class PluginService { async install(origin: GrowiPluginOrigin): Promise { // download const ghUrl = new URL(origin.url); const ghPathname = ghUrl.pathname; const match = ghPathname.match(githubReposIdPattern); if (ghUrl.hostname !== 'github.com' || match == null) { throw new Error('The GitHub Repository URL is invalid.'); } const ghOrganizationName = match[1]; const ghReposName = match[2]; // download github repository to local file system await this.download(`${ghUrl.href}/archive/refs/heads/main.zip`, ghOrganizationName, ghReposName); // save plugin metadata const installedPath = `${ghOrganizationName}/${ghReposName}`; const plugins = await PluginService.detectPlugins(origin, installedPath); await this.savePluginMetaData(plugins); return; } async download(url: string, ghOrganizationName: string, ghReposName: string): Promise { const zipFilePath = path.join(pluginStoringPath, 'main.zip'); const unzipFolderPath = path.join(pluginStoringPath, ghOrganizationName); const unzippedFolderPath = `${unzipFolderPath}/${ghReposName}-main`; const newFolderPath = `${unzipFolderPath}/${ghReposName}`; const deleteFile = (path: fs.PathLike) => { fs.unlink(path, (err) => { if (err) throw err; }); }; const downloadRepository = () => { const writeStream = fs.createWriteStream(zipFilePath); return new Promise((resolve, reject) => { request .get(url) .pipe(writeStream) .on('close', () => writeStream.close()) .on('finish', () => resolve()) .on('error', (error: any) => reject(error)); }); }; const unzip = () => { const stream = fs.createReadStream(zipFilePath); return new Promise((resolve, reject) => { stream.pipe(unzipper.Extract({ path: unzipFolderPath })) .on('finish', () => { deleteFile(zipFilePath); resolve(); }) .on('error', (error: any) => reject(error)); }); }; const rename = async() => { fs.renameSync(unzippedFolderPath, newFolderPath); }; await downloadRepository(); await unzip(); await rename(); return; } async savePluginMetaData(plugins: GrowiPlugin[]): Promise { const GrowiPlugin = mongoose.model('GrowiPlugin'); await GrowiPlugin.insertMany(plugins); } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types static async detectPlugins(origin: GrowiPluginOrigin, installedPath: string, parentPackageJson?: any): Promise { const packageJsonPath = path.resolve(pluginStoringPath, installedPath, 'package.json'); const packageJson = await import(packageJsonPath); const { growiPlugin } = packageJson; const { name: packageName, description: packageDesc, author: packageAuthor, } = parentPackageJson ?? packageJson; if (growiPlugin == null) { throw new Error('This package does not include \'growiPlugin\' section.'); } // detect sub plugins for monorepo if (growiPlugin.isMonorepo && growiPlugin.packages != null) { const plugins = await Promise.all( growiPlugin.packages.map(async(subPackagePath) => { const subPackageInstalledPath = path.join(installedPath, subPackagePath); return this.detectPlugins(origin, subPackageInstalledPath, packageJson); }), ); return plugins.flat(); } if (growiPlugin.types == null) { throw new Error('\'growiPlugin\' section must have a \'types\' property.'); } const plugin = { isEnabled: true, installedPath, origin, meta: { name: growiPlugin.name ?? packageName, desc: growiPlugin.desc ?? packageDesc, author: growiPlugin.author ?? packageAuthor, types: growiPlugin.types, }, }; logger.info('Plugin detected => ', plugin); return [plugin]; } async listPlugins(): Promise { return []; } }