g2g-transfer.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import { createReadStream, ReadStream } from 'fs';
  2. import { basename } from 'path';
  3. import { Readable } from 'stream';
  4. // eslint-disable-next-line no-restricted-imports
  5. import rawAxios from 'axios';
  6. import FormData from 'form-data';
  7. import { Types as MongooseTypes } from 'mongoose';
  8. import { G2G_PROGRESS_STATUS } from '~/interfaces/g2g-transfer';
  9. import TransferKeyModel from '~/server/models/transfer-key';
  10. import { createBatchStream } from '~/server/util/batch-stream';
  11. import axios from '~/utils/axios';
  12. import loggerFactory from '~/utils/logger';
  13. import { TransferKey } from '~/utils/vo/transfer-key';
  14. import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
  15. const logger = loggerFactory('growi:service:g2g-transfer');
  16. export const X_GROWI_TRANSFER_KEY_HEADER_NAME = 'x-growi-transfer-key';
  17. export const uploadConfigKeys = [
  18. 'app:fileUploadType',
  19. 'app:useOnlyEnvVarForFileUploadType',
  20. 'aws:referenceFileWithRelayMode',
  21. 'aws:lifetimeSecForTemporaryUrl',
  22. 'gcs:apiKeyJsonPath',
  23. 'gcs:bucket',
  24. 'gcs:uploadNamespace',
  25. 'gcs:referenceFileWithRelayMode',
  26. 'gcs:useOnlyEnvVarsForSomeOptions',
  27. ];
  28. /**
  29. * Data used for comparing to/from GROWI information
  30. */
  31. export type IDataGROWIInfo = {
  32. version: string
  33. userUpperLimit: number | null // Handle null as Infinity
  34. fileUploadDisabled: boolean;
  35. fileUploadTotalLimit: number | null // Handle null as Infinity
  36. attachmentInfo: {
  37. type: string,
  38. bucket?: string,
  39. customEndpoint?: string, // for S3
  40. uploadNamespace?: string, // for GCS
  41. };
  42. }
  43. /**
  44. * File metadata in storage
  45. * TODO: mv this to "./file-uploader/uploader"
  46. */
  47. interface FileMeta {
  48. name: string;
  49. size: number;
  50. }
  51. /**
  52. * Return type for {@link Pusher.getTransferability}
  53. */
  54. type IGetTransferabilityReturn = { canTransfer: true; } | { canTransfer: false; reason: string; };
  55. interface Pusher {
  56. /**
  57. * Send to-growi a request to get growi info
  58. * @param {TransferKey} tk Transfer key
  59. */
  60. askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo>
  61. /**
  62. * Check if transfering is proceedable
  63. * @param {IDataGROWIInfo} fromGROWIInfo
  64. */
  65. getTransferability(fromGROWIInfo: IDataGROWIInfo): Promise<IGetTransferabilityReturn>
  66. /**
  67. * List files in the storage
  68. * @param {TransferKey} tk Transfer key
  69. */
  70. listFilesInStorage(tk: TransferKey): Promise<FileMeta[]>
  71. /**
  72. * Transfer all Attachment data to destination GROWI
  73. * @param {TransferKey} tk Transfer key
  74. */
  75. transferAttachments(tk: TransferKey): Promise<void>
  76. /**
  77. * Start transfer data between GROWIs
  78. * @param {TransferKey} tk TransferKey object
  79. * @param {string[]} collections Collection name string array
  80. * @param {any} optionsMap Options map
  81. */
  82. startTransfer(
  83. tk: TransferKey,
  84. user: any,
  85. toGROWIInfo: IDataGROWIInfo,
  86. collections: string[],
  87. optionsMap: any,
  88. ): Promise<void>
  89. }
  90. interface Receiver {
  91. /**
  92. * Check if key is not expired
  93. * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
  94. * @param {string} key Transfer key
  95. */
  96. validateTransferKey(key: string): Promise<void>
  97. /**
  98. * Check if key is not expired
  99. * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
  100. */
  101. answerGROWIInfo(): Promise<IDataGROWIInfo>
  102. /**
  103. * DO NOT USE TransferKeyModel.create() directly, instead, use this method to create a TransferKey document.
  104. * This method receives appSiteUrlOrigin to create a TransferKey document and returns generated transfer key string.
  105. * UUID is the same value as the created document's _id.
  106. * @param {string} appSiteUrlOrigin GROWI app site URL origin
  107. * @returns {string} Transfer key string (e.g. http://localhost:3000__grw_internal_tranferkey__<uuid>)
  108. */
  109. createTransferKey(appSiteUrlOrigin: string): Promise<string>
  110. /**
  111. * Receive transfer request and import data.
  112. * @param {Readable} zippedGROWIDataStream
  113. * @returns {void}
  114. */
  115. receive(zippedGROWIDataStream: Readable): Promise<void>
  116. }
  117. const generateAxiosRequestConfigWithTransferKey = (tk: TransferKey, additionalHeaders: {[key: string]: string} = {}) => {
  118. const { appSiteUrlOrigin, key } = tk;
  119. return {
  120. baseURL: appSiteUrlOrigin,
  121. headers: {
  122. ...additionalHeaders,
  123. [X_GROWI_TRANSFER_KEY_HEADER_NAME]: key,
  124. },
  125. maxBodyLength: Infinity,
  126. };
  127. };
  128. export class G2GTransferPusherService implements Pusher {
  129. crowi: any;
  130. // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  131. constructor(crowi: any) {
  132. this.crowi = crowi;
  133. }
  134. public async askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo> {
  135. // axios get
  136. let toGROWIInfo: IDataGROWIInfo;
  137. try {
  138. const res = await axios.get('/_api/v3/g2g-transfer/growi-info', generateAxiosRequestConfigWithTransferKey(tk));
  139. toGROWIInfo = res.data.growiInfo;
  140. }
  141. catch (err) {
  142. logger.error(err);
  143. throw new G2GTransferError('Failed to retrieve growi info.', G2GTransferErrorCode.FAILED_TO_RETRIEVE_GROWI_INFO);
  144. }
  145. return toGROWIInfo;
  146. }
  147. /**
  148. * Returns whether g2g transfer is possible and reason for failure
  149. * @param toGROWIInfo to-growi info
  150. * @returns Whether g2g transfer is possible and reason for failure
  151. */
  152. public async getTransferability(toGROWIInfo: IDataGROWIInfo): Promise<IGetTransferabilityReturn> {
  153. const { fileUploadService } = this.crowi;
  154. const version = this.crowi.version;
  155. if (version !== toGROWIInfo.version) {
  156. return {
  157. canTransfer: false,
  158. // TODO: i18n for reason
  159. reason: `Growi versions mismatch. This Growi: ${version} / new Growi: ${toGROWIInfo.version}.`,
  160. };
  161. }
  162. const activeUserCount = await this.crowi.model('User').countActiveUsers();
  163. if ((toGROWIInfo.userUpperLimit ?? Infinity) < activeUserCount) {
  164. return {
  165. canTransfer: false,
  166. // TODO: i18n for reason
  167. reason: `The number of active users (${activeUserCount} users) exceeds the limit of new Growi (to up ${toGROWIInfo.userUpperLimit} users).`,
  168. };
  169. }
  170. if (toGROWIInfo.fileUploadDisabled) {
  171. return {
  172. canTransfer: false,
  173. // TODO: i18n for reason
  174. reason: 'File upload is disabled in new Growi.',
  175. };
  176. }
  177. const totalFileSize = await fileUploadService.getTotalFileSize();
  178. if ((toGROWIInfo.fileUploadTotalLimit ?? Infinity) < totalFileSize) {
  179. return {
  180. canTransfer: false,
  181. // TODO: i18n for reason
  182. // eslint-disable-next-line max-len
  183. reason: `Total file size exceeds file upload limit of new Growi. Requires ${totalFileSize.toLocaleString()} bytes, but got ${(toGROWIInfo.fileUploadTotalLimit ?? Infinity).toLocaleString()} bytes.`,
  184. };
  185. }
  186. return { canTransfer: true };
  187. }
  188. public async listFilesInStorage(tk: TransferKey): Promise<FileMeta[]> {
  189. try {
  190. const { data: { files } } = await axios.get<{ files: FileMeta[] }>('/_api/v3/g2g-transfer/files', generateAxiosRequestConfigWithTransferKey(tk));
  191. return files;
  192. }
  193. catch (err) {
  194. logger.error(err);
  195. throw new G2GTransferError('Failed to retrieve file metadata', G2GTransferErrorCode.FAILED_TO_RETRIEVE_FILE_METADATA);
  196. }
  197. }
  198. public async transferAttachments(tk: TransferKey): Promise<void> {
  199. const BATCH_SIZE = 100;
  200. const { fileUploadService } = this.crowi;
  201. const Attachment = this.crowi.model('Attachment');
  202. const filesFromNewGrowi = await this.listFilesInStorage(tk);
  203. /**
  204. * Given these documents,
  205. *
  206. * | fileName | fileSize |
  207. * | -- | -- |
  208. * | a.png | 1024 |
  209. * | b.png | 2048 |
  210. * | c.png | 1024 |
  211. * | d.png | 2048 |
  212. *
  213. * this filter
  214. *
  215. * ```jsonc
  216. * {
  217. * $and: [
  218. * // a file transferred
  219. * {
  220. * $or: [
  221. * { fileName: { $ne: "a.png" } },
  222. * { fileSize: { $ne: 1024 } }
  223. * ]
  224. * },
  225. * // a file failed to transfer
  226. * {
  227. * $or: [
  228. * { fileName: { $ne: "b.png" } },
  229. * { fileSize: { $ne: 0 } }
  230. * ]
  231. * }
  232. * ]
  233. * }
  234. * ```
  235. *
  236. * results in
  237. *
  238. * | fileName | fileSize |
  239. * | -- | -- |
  240. * | b.png | 2048 |
  241. * | c.png | 1024 |
  242. * | d.png | 2048 |
  243. */
  244. const filter = filesFromNewGrowi.length > 0 ? {
  245. $and: filesFromNewGrowi.map(({ name, size }) => ({
  246. $or: [
  247. { fileName: { $ne: basename(name) } },
  248. { fileSize: { $ne: size } },
  249. ],
  250. })),
  251. } : {};
  252. const attachmentsCursor = await Attachment.find(filter).cursor();
  253. const batchStream = createBatchStream(BATCH_SIZE);
  254. for await (const attachmentBatch of attachmentsCursor.pipe(batchStream)) {
  255. for await (const attachment of attachmentBatch) {
  256. logger.debug(`processing attachment: ${attachment}`);
  257. let fileStream;
  258. try {
  259. // get read stream of each attachment
  260. fileStream = await fileUploadService.findDeliveryFile(attachment);
  261. }
  262. catch (err) {
  263. logger.warn(`Error occured when getting Attachment(ID=${attachment.id}), skipping: `, err);
  264. continue;
  265. }
  266. // TODO: get attachmentLists from destination GROWI to avoid transferring files that the dest GROWI has
  267. // TODO: refresh transfer key per 1 hour
  268. // post each attachment file data to receiver
  269. try {
  270. await this.doTransferAttachment(tk, attachment, fileStream);
  271. }
  272. catch (err) {
  273. logger.error(`Error occured when uploading attachment(ID=${attachment.id})`, err);
  274. }
  275. }
  276. }
  277. }
  278. // eslint-disable-next-line max-len
  279. public async startTransfer(tk: TransferKey, user: any, toGROWIInfo: IDataGROWIInfo, collections: string[], optionsMap: any, shouldEmit = true): Promise<void> {
  280. const socket = this.crowi.socketIoService.getAdminSocket();
  281. if (shouldEmit) {
  282. socket.emit('admin:g2gProgress', {
  283. mongo: G2G_PROGRESS_STATUS.IN_PROGRESS,
  284. attachments: G2G_PROGRESS_STATUS.PENDING,
  285. });
  286. }
  287. const targetConfigKeys = uploadConfigKeys;
  288. const uploadConfigs = Object.fromEntries(targetConfigKeys.map((key) => {
  289. return [key, this.crowi.configManager.getConfig('crowi', key)];
  290. }));
  291. let zipFileStream: ReadStream;
  292. try {
  293. const shouldEmit = false;
  294. const zipFileStat = await this.crowi.exportService.export(collections, shouldEmit);
  295. const zipFilePath = zipFileStat.zipFilePath;
  296. zipFileStream = createReadStream(zipFilePath);
  297. }
  298. catch (err) {
  299. logger.error(err);
  300. socket.emit('admin:g2gProgress', {
  301. mongo: G2G_PROGRESS_STATUS.ERROR,
  302. attachments: G2G_PROGRESS_STATUS.PENDING,
  303. });
  304. socket.emit('admin:g2gError', { message: 'Failed to generate GROWI archive file', key: 'admin:g2g:error_generate_growi_archive' });
  305. throw err;
  306. }
  307. // Send a zip file to other growi via axios
  308. try {
  309. // Use FormData to immitate browser's form data object
  310. const form = new FormData();
  311. const appTitle = this.crowi.appService.getAppTitle();
  312. form.append('transferDataZipFile', zipFileStream, `${appTitle}-${Date.now}.growi.zip`);
  313. form.append('collections', JSON.stringify(collections));
  314. form.append('optionsMap', JSON.stringify(optionsMap));
  315. form.append('operatorUserId', user._id.toString());
  316. form.append('uploadConfigs', JSON.stringify(uploadConfigs));
  317. await rawAxios.post('/_api/v3/g2g-transfer/', form, generateAxiosRequestConfigWithTransferKey(tk, form.getHeaders()));
  318. }
  319. catch (err) {
  320. logger.error(err);
  321. socket.emit('admin:g2gProgress', {
  322. mongo: G2G_PROGRESS_STATUS.ERROR,
  323. attachments: G2G_PROGRESS_STATUS.PENDING,
  324. });
  325. socket.emit('admin:g2gError', { message: 'Failed to send GROWI archive file to new GROWI', key: 'admin:g2g:error_send_growi_archive' });
  326. throw err;
  327. }
  328. if (shouldEmit) {
  329. socket.emit('admin:g2gProgress', {
  330. mongo: G2G_PROGRESS_STATUS.COMPLETED,
  331. attachments: G2G_PROGRESS_STATUS.IN_PROGRESS,
  332. });
  333. }
  334. try {
  335. await this.transferAttachments(tk);
  336. }
  337. catch (err) {
  338. logger.error(err);
  339. socket.emit('admin:g2gProgress', {
  340. mongo: G2G_PROGRESS_STATUS.COMPLETED,
  341. attachments: G2G_PROGRESS_STATUS.ERROR,
  342. });
  343. socket.emit('admin:g2gError', { message: 'Failed to transfer attachments', key: 'admin:g2g:error_upload_attachment' });
  344. throw err;
  345. }
  346. if (shouldEmit) {
  347. socket.emit('admin:g2gProgress', {
  348. mongo: G2G_PROGRESS_STATUS.COMPLETED,
  349. attachments: G2G_PROGRESS_STATUS.COMPLETED,
  350. });
  351. }
  352. }
  353. /**
  354. * transfer attachment to destination GROWI
  355. * @param tk Transfer key
  356. * @param attachment Attachment model instance
  357. * @param fileStream Attachment data(loaded from storage)
  358. */
  359. private async doTransferAttachment(tk: TransferKey, attachment, fileStream: Readable) {
  360. // Use FormData to immitate browser's form data object
  361. const form = new FormData();
  362. form.append('content', fileStream, attachment.fileName);
  363. form.append('attachmentMetadata', JSON.stringify(attachment));
  364. await rawAxios.post('/_api/v3/g2g-transfer/attachment', form, generateAxiosRequestConfigWithTransferKey(tk, form.getHeaders()));
  365. }
  366. }
  367. export class G2GTransferReceiverService implements Receiver {
  368. crowi: any;
  369. constructor(crowi: any) {
  370. this.crowi = crowi;
  371. }
  372. public async validateTransferKey(key: string): Promise<void> {
  373. const transferKey = await (TransferKeyModel as any).findOne({ key });
  374. if (transferKey == null) {
  375. throw new Error(`Transfer key "${key}" was expired or not found`);
  376. }
  377. try {
  378. TransferKey.parse(transferKey.keyString);
  379. }
  380. catch (err) {
  381. logger.error(err);
  382. throw new Error(`Transfer key "${key}" is invalid`);
  383. }
  384. }
  385. /**
  386. * generate GROWIInfo
  387. * @returns
  388. */
  389. public async answerGROWIInfo(): Promise<IDataGROWIInfo> {
  390. // TODO: add attachment file limit
  391. const { version, configManager, fileUploadService } = this.crowi;
  392. const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
  393. const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
  394. const fileUploadTotalLimit = configManager.getFileUploadTotalLimit();
  395. const isWritable = await fileUploadService.isWritable();
  396. const attachmentInfo = {
  397. type: configManager.getConfig('crowi', 'app:fileUploadType'),
  398. bucket: undefined,
  399. customEndpoint: undefined, // for S3
  400. uploadNamespace: undefined, // for GCS
  401. writable: isWritable,
  402. };
  403. // put storage location info to check storage identification
  404. switch (attachmentInfo.type) {
  405. case 'aws':
  406. attachmentInfo.bucket = configManager.getConfig('crowi', 'aws:s3Bucket');
  407. attachmentInfo.customEndpoint = configManager.getConfig('crowi', 'aws:s3CustomEndpoint');
  408. break;
  409. case 'gcs':
  410. attachmentInfo.bucket = configManager.getConfig('crowi', 'gcs:bucket');
  411. attachmentInfo.uploadNamespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
  412. break;
  413. default:
  414. }
  415. return {
  416. userUpperLimit,
  417. fileUploadDisabled,
  418. fileUploadTotalLimit,
  419. version,
  420. attachmentInfo,
  421. };
  422. }
  423. public async createTransferKey(appSiteUrlOrigin: string): Promise<string> {
  424. const uuid = new MongooseTypes.ObjectId().toString();
  425. const transferKeyString = TransferKey.generateKeyString(uuid, appSiteUrlOrigin);
  426. // Save TransferKey document
  427. let tkd;
  428. try {
  429. tkd = await TransferKeyModel.create({ _id: uuid, keyString: transferKeyString, key: uuid });
  430. }
  431. catch (err) {
  432. logger.error(err);
  433. throw err;
  434. }
  435. return tkd.keyString;
  436. }
  437. public async receive(zipfile: Readable): Promise<void> {
  438. // Import data
  439. // Call onCompleteTransfer when finished
  440. return;
  441. }
  442. /**
  443. *
  444. * @param content Pushed attachment data from source GROWI
  445. * @param attachmentMap Map-ped Attachment instance
  446. * @returns
  447. */
  448. public async receiveAttachment(content: Readable, attachmentMap): Promise<void> {
  449. // TODO: test with S3, local
  450. const { fileUploadService } = this.crowi;
  451. return fileUploadService.uploadAttachment(content, attachmentMap);
  452. }
  453. /**
  454. * Sync DB, etc.
  455. * @returns {Promise<void>}
  456. */
  457. private async onCompleteTransfer(): Promise<void> { return }
  458. }