import.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import { SCOPE } from '@growi/core/dist/interfaces';
  2. import { ErrorV3 } from '@growi/core/dist/models';
  3. import type { Router } from 'express';
  4. import { SupportedAction } from '~/interfaces/activity';
  5. import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
  6. import type Crowi from '~/server/crowi';
  7. import { accessTokenParser } from '~/server/middlewares/access-token-parser';
  8. import type { ImportSettings } from '~/server/service/import';
  9. import { getImportService } from '~/server/service/import';
  10. import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
  11. import type { ZipFileStat } from '~/server/service/interfaces/export';
  12. import loggerFactory from '~/utils/logger';
  13. import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
  14. const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
  15. const path = require('path');
  16. const express = require('express');
  17. const multer = require('multer');
  18. const router = express.Router();
  19. /**
  20. * @swagger
  21. *
  22. * components:
  23. * schemas:
  24. * GrowiArchiveImportOption:
  25. * description: GrowiArchiveImportOption
  26. * type: object
  27. * properties:
  28. * mode:
  29. * description: Import mode
  30. * type: string
  31. * enum: [insert, upsert, flushAndInsert]
  32. * ImportStatus:
  33. * description: ImportStatus
  34. * type: object
  35. * properties:
  36. * isTheSameVersion:
  37. * type: boolean
  38. * description: whether the version of the uploaded data is the same as the current GROWI version
  39. * zipFileStat:
  40. * type: object
  41. * description: the property object
  42. * progressList:
  43. * type: array
  44. * items:
  45. * type: object
  46. * description: progress data for each exporting collections
  47. * isImporting:
  48. * type: boolean
  49. * description: whether the current importing job exists or not
  50. * FileImportResponse:
  51. * type: object
  52. * properties:
  53. * meta:
  54. * type: object
  55. * properties:
  56. * version:
  57. * type: string
  58. * url:
  59. * type: string
  60. * passwordSeed:
  61. * type: string
  62. * exportedAt:
  63. * type: string
  64. * format: date-time
  65. * envVars:
  66. * type: object
  67. * properties:
  68. * ELASTICSEARCH_URI:
  69. * type: string
  70. * fileName:
  71. * type: string
  72. * zipFilePath:
  73. * type: string
  74. * fileStat:
  75. * type: object
  76. * properties:
  77. * dev:
  78. * type: integer
  79. * mode:
  80. * type: integer
  81. * nlink:
  82. * type: integer
  83. * uid:
  84. * type: integer
  85. * gid:
  86. * type: integer
  87. * rdev:
  88. * type: integer
  89. * blksize:
  90. * type: integer
  91. * ino:
  92. * type: integer
  93. * size:
  94. * type: integer
  95. * blocks:
  96. * type: integer
  97. * atime:
  98. * type: string
  99. * format: date-time
  100. * mtime:
  101. * type: string
  102. * format: date-time
  103. * ctime:
  104. * type: string
  105. * format: date-time
  106. * birthtime:
  107. * type: string
  108. * format: date-time
  109. * innerFileStats:
  110. * type: array
  111. * items:
  112. * type: object
  113. * properties:
  114. * fileName:
  115. * type: string
  116. * collectionName:
  117. * type: string
  118. * size:
  119. * type: integer
  120. * nullable: true
  121. */
  122. export default function route(crowi: Crowi): Router {
  123. const { growiBridgeService, socketIoService } = crowi;
  124. const importService = getImportService();
  125. const loginRequired = require('../../middlewares/login-required')(crowi);
  126. const adminRequired = require('../../middlewares/admin-required')(crowi);
  127. const addActivity = generateAddActivityMiddleware();
  128. const adminEvent = crowi.event('admin');
  129. const activityEvent = crowi.event('activity');
  130. // setup event
  131. adminEvent.on('onProgressForImport', (data) => {
  132. socketIoService.getAdminSocket().emit('admin:onProgressForImport', data);
  133. });
  134. adminEvent.on('onTerminateForImport', (data) => {
  135. socketIoService.getAdminSocket().emit('admin:onTerminateForImport', data);
  136. });
  137. adminEvent.on('onErrorForImport', (data) => {
  138. socketIoService.getAdminSocket().emit('admin:onErrorForImport', data);
  139. });
  140. const uploads = multer({
  141. storage: multer.diskStorage({
  142. destination: (req, file, cb) => {
  143. cb(null, importService.baseDir);
  144. },
  145. filename(req, file, cb) {
  146. // to prevent hashing the file name. files with same name will be overwritten.
  147. cb(null, file.originalname);
  148. },
  149. }),
  150. fileFilter: (req, file, cb) => {
  151. if (path.extname(file.originalname) === '.zip') {
  152. return cb(null, true);
  153. }
  154. cb(new Error('Only ".zip" is allowed'));
  155. },
  156. });
  157. /**
  158. * @swagger
  159. *
  160. * /import:
  161. * get:
  162. * tags: [Import]
  163. * security:
  164. * - bearer: []
  165. * - accessTokenInQuery: []
  166. * summary: /import
  167. * description: Get import settings params
  168. * responses:
  169. * 200:
  170. * description: import settings params
  171. * content:
  172. * application/json:
  173. * schema:
  174. * properties:
  175. * importSettingsParams:
  176. * type: object
  177. * description: import settings params
  178. * properties:
  179. * esaTeamName:
  180. * type: string
  181. * description: the team name of esa.io
  182. * esaAccessToken:
  183. * type: string
  184. * description: the access token of esa.io
  185. * qiitaTeamName:
  186. * type: string
  187. * description: the team name of qiita.com
  188. * qiitaAccessToken:
  189. * type: string
  190. * description: the access token of qiita.com
  191. */
  192. router.get(
  193. '/',
  194. accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
  195. loginRequired,
  196. adminRequired,
  197. async (req, res) => {
  198. try {
  199. const importSettingsParams = {
  200. esaTeamName: await crowi.configManager.getConfig(
  201. 'importer:esa:team_name',
  202. ),
  203. esaAccessToken: await crowi.configManager.getConfig(
  204. 'importer:esa:access_token',
  205. ),
  206. qiitaTeamName: await crowi.configManager.getConfig(
  207. 'importer:qiita:team_name',
  208. ),
  209. qiitaAccessToken: await crowi.configManager.getConfig(
  210. 'importer:qiita:access_token',
  211. ),
  212. };
  213. return res.apiv3({
  214. importSettingsParams,
  215. });
  216. } catch (err) {
  217. return res.apiv3Err(err, 500);
  218. }
  219. },
  220. );
  221. /**
  222. * @swagger
  223. *
  224. * /import/status:
  225. * get:
  226. * tags: [Import]
  227. * security:
  228. * - bearer: []
  229. * - accessTokenInQuery: []
  230. * summary: /import/status
  231. * description: Get properties of stored zip files for import
  232. * responses:
  233. * 200:
  234. * description: the zip file statuses
  235. * content:
  236. * application/json:
  237. * schema:
  238. * properties:
  239. * status:
  240. * $ref: '#/components/schemas/ImportStatus'
  241. */
  242. router.get(
  243. '/status',
  244. accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
  245. loginRequired,
  246. adminRequired,
  247. async (req, res) => {
  248. try {
  249. const status = await importService.getStatus();
  250. return res.apiv3(status);
  251. } catch (err) {
  252. return res.apiv3Err(err, 500);
  253. }
  254. },
  255. );
  256. /**
  257. * @swagger
  258. *
  259. * /import:
  260. * post:
  261. * tags: [Import]
  262. * security:
  263. * - bearer: []
  264. * - accessTokenInQuery: []
  265. * summary: /import
  266. * description: import a collection from a zipped json
  267. * requestBody:
  268. * required: true
  269. * content:
  270. * application/json:
  271. * schema:
  272. * type: object
  273. * properties:
  274. * fileName:
  275. * description: the file name of zip file
  276. * type: string
  277. * collections:
  278. * description: collection names to import
  279. * type: array
  280. * items:
  281. * type: string
  282. * options:
  283. * description: |
  284. * the array of importing option that have collection name as the key
  285. * additionalProperties:
  286. * type: array
  287. * items:
  288. * $ref: '#/components/schemas/GrowiArchiveImportOption'
  289. * responses:
  290. * 200:
  291. * description: Import process has requested
  292. */
  293. router.post(
  294. '/',
  295. accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
  296. loginRequired,
  297. adminRequired,
  298. addActivity,
  299. async (req, res) => {
  300. // TODO: add express validator
  301. const { fileName, collections, options } = req.body;
  302. // pages collection can only be imported by upsert if isV5Compatible is true
  303. const isV5Compatible =
  304. crowi.configManager.getConfig('app:isV5Compatible');
  305. const isImportPagesCollection = collections.includes('pages');
  306. if (isV5Compatible && isImportPagesCollection) {
  307. /** @type {ImportOptionForPages} */
  308. const option = options.find((opt) => opt.collectionName === 'pages');
  309. if (option.mode !== 'upsert') {
  310. return res.apiv3Err(
  311. new ErrorV3(
  312. 'Upsert is only available for importing pages collection.',
  313. 'only_upsert_available',
  314. ),
  315. );
  316. }
  317. }
  318. const isMaintenanceMode = crowi.appService.isMaintenanceMode();
  319. if (!isMaintenanceMode) {
  320. return res.apiv3Err(
  321. new ErrorV3(
  322. 'GROWI is not maintenance mode. To import data, please activate the maintenance mode first.',
  323. 'not_maintenance_mode',
  324. ),
  325. );
  326. }
  327. const zipFile = importService.getFile(fileName);
  328. // return response first
  329. res.apiv3();
  330. /*
  331. * unzip, parse
  332. */
  333. let meta: object;
  334. let fileStatsToImport: {
  335. fileName: string;
  336. collectionName: string;
  337. size: number;
  338. }[];
  339. try {
  340. // unzip
  341. await importService.unzip(zipFile);
  342. // eslint-disable-next-line no-unused-vars
  343. const parseZipResult = await growiBridgeService.parseZipFile(zipFile);
  344. if (parseZipResult == null) {
  345. throw new Error('parseZipFile returns null');
  346. }
  347. meta = parseZipResult.meta;
  348. // filter innerFileStats
  349. fileStatsToImport = parseZipResult.innerFileStats.filter(
  350. ({ collectionName }) => {
  351. return collections.includes(collectionName);
  352. },
  353. );
  354. } catch (err) {
  355. logger.error(err);
  356. adminEvent.emit('onErrorForImport', { message: err.message });
  357. return;
  358. }
  359. /*
  360. * validate with meta.json
  361. */
  362. try {
  363. importService.validate(meta);
  364. } catch (err) {
  365. logger.error(err);
  366. adminEvent.emit('onErrorForImport', { message: err.message });
  367. return;
  368. }
  369. // generate maps of ImportSettings to import
  370. // Use the Map for a potential fix for the code scanning alert no. 895: Prototype-polluting assignment
  371. const importSettingsMap = new Map<string, ImportSettings>();
  372. fileStatsToImport.forEach(({ fileName, collectionName }) => {
  373. // instanciate GrowiArchiveImportOption
  374. const option: GrowiArchiveImportOption = options.find(
  375. (opt) => opt.collectionName === collectionName,
  376. );
  377. // generate options
  378. const importSettings = {
  379. mode: option.mode,
  380. jsonFileName: fileName,
  381. overwriteParams: generateOverwriteParams(
  382. collectionName,
  383. req.user._id,
  384. option,
  385. ),
  386. } satisfies ImportSettings;
  387. importSettingsMap.set(collectionName, importSettings);
  388. });
  389. /*
  390. * import
  391. */
  392. try {
  393. importService.import(collections, importSettingsMap);
  394. const parameters = {
  395. action: SupportedAction.ACTION_ADMIN_GROWI_DATA_IMPORTED,
  396. };
  397. activityEvent.emit('update', res.locals.activity._id, parameters);
  398. } catch (err) {
  399. logger.error(err);
  400. adminEvent.emit('onErrorForImport', { message: err.message });
  401. }
  402. },
  403. );
  404. /**
  405. * @swagger
  406. *
  407. * /import/upload:
  408. * post:
  409. * tags: [Import]
  410. * security:
  411. * - bearer: []
  412. * - accessTokenInQuery: []
  413. * summary: /import/upload
  414. * description: upload a zip file
  415. * requestBody:
  416. * content:
  417. * multipart/form-data:
  418. * schema:
  419. * type: object
  420. * properties:
  421. * file:
  422. * type: string
  423. * format: binary
  424. * responses:
  425. * 200:
  426. * description: the file is uploaded
  427. * content:
  428. * application/json:
  429. * schema:
  430. * $ref: '#/components/schemas/FileImportResponse'
  431. */
  432. router.post(
  433. '/upload',
  434. accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
  435. loginRequired,
  436. adminRequired,
  437. uploads.single('file'),
  438. addActivity,
  439. async (req, res) => {
  440. const { file } = req;
  441. const zipFile = importService.getFile(file.filename);
  442. let data: ZipFileStat | null;
  443. try {
  444. data = await growiBridgeService.parseZipFile(zipFile);
  445. } catch (err) {
  446. // TODO: use ApiV3Error
  447. logger.error(err);
  448. return res.status(500).send({ status: 'ERROR' });
  449. }
  450. try {
  451. // validate with meta.json
  452. importService.validate(data?.meta);
  453. const parameters = {
  454. action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
  455. };
  456. activityEvent.emit('update', res.locals.activity._id, parameters);
  457. return res.apiv3(data);
  458. } catch {
  459. const msg =
  460. 'The version of this GROWI and the uploaded GROWI data are not the same';
  461. const validationErr = 'versions-are-not-met';
  462. return res.apiv3Err(new ErrorV3(msg, validationErr), 500);
  463. }
  464. },
  465. );
  466. /**
  467. * @swagger
  468. *
  469. * /import/all:
  470. * delete:
  471. * tags: [Import]
  472. * security:
  473. * - bearer: []
  474. * - accessTokenInQuery: []
  475. * summary: /import/all
  476. * description: Delete all zip files
  477. * responses:
  478. * 200:
  479. * description: all files are deleted
  480. */
  481. router.delete(
  482. '/all',
  483. accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
  484. loginRequired,
  485. adminRequired,
  486. async (req, res) => {
  487. try {
  488. importService.deleteAllZipFiles();
  489. return res.apiv3();
  490. } catch (err) {
  491. logger.error(err);
  492. return res.apiv3Err(err, 500);
  493. }
  494. },
  495. );
  496. return router;
  497. }