import.ts 14 KB

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