import.ts 15 KB

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