import.js 14 KB

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