g2g-transfer.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import { createReadStream, ReadStream } from 'fs';
  2. import { Readable } from 'stream';
  3. // eslint-disable-next-line no-restricted-imports
  4. import rawAxios from 'axios';
  5. import FormData from 'form-data';
  6. import { Types as MongooseTypes } from 'mongoose';
  7. import TransferKeyModel from '~/server/models/transfer-key';
  8. import { createBatchStream } from '~/server/util/batch-stream';
  9. import axios from '~/utils/axios';
  10. import loggerFactory from '~/utils/logger';
  11. import { TransferKey } from '~/utils/vo/transfer-key';
  12. import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
  13. const logger = loggerFactory('growi:service:g2g-transfer');
  14. export const X_GROWI_TRANSFER_KEY_HEADER_NAME = 'x-growi-transfer-key';
  15. /**
  16. * Data used for comparing to/from GROWI information
  17. */
  18. export type IDataGROWIInfo = {
  19. version: string
  20. userUpperLimit: number | null // Handle null as Infinity
  21. attachmentInfo: {
  22. type: string,
  23. bucket?: string,
  24. customEndpoint?: string, // for S3
  25. uploadNamespace?: string, // for GCS
  26. };
  27. }
  28. interface Pusher {
  29. /**
  30. * Send to-growi a request to get growi info
  31. * @param {TransferKey} tk Transfer key
  32. */
  33. askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo>
  34. /**
  35. * Check if transfering is proceedable
  36. * @param {IDataGROWIInfo} fromGROWIInfo
  37. */
  38. canTransfer(fromGROWIInfo: IDataGROWIInfo): Promise<boolean>
  39. /**
  40. * Transfer all Attachment data to destination GROWI
  41. * @param {TransferKey} tk Transfer key
  42. */
  43. transferAttachments(tk: TransferKey): Promise<void>
  44. /**
  45. * Start transfer data between GROWIs
  46. * @param {TransferKey} tk TransferKey object
  47. * @param {string[]} collections Collection name string array
  48. * @param {any} optionsMap Options map
  49. */
  50. startTransfer(tk: TransferKey, user: any, toGROWIInfo: IDataGROWIInfo, collections: string[], optionsMap: any): Promise<void>
  51. }
  52. interface Receiver {
  53. /**
  54. * Check if key is not expired
  55. * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
  56. * @param {string} key Transfer key
  57. */
  58. validateTransferKey(key: string): Promise<void>
  59. /**
  60. * Check if key is not expired
  61. * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
  62. */
  63. answerGROWIInfo(): Promise<IDataGROWIInfo>
  64. /**
  65. * DO NOT USE TransferKeyModel.create() directly, instead, use this method to create a TransferKey document.
  66. * This method receives appSiteUrl to create a TransferKey document and returns generated transfer key string.
  67. * UUID is the same value as the created document's _id.
  68. * @param {URL} appSiteUrl URL type appSiteUrl
  69. * @returns {string} Transfer key string (e.g. http://localhost:3000__grw_internal_tranferkey__<uuid>)
  70. */
  71. createTransferKey(appSiteUrl: URL): Promise<string>
  72. /**
  73. * Receive transfer request and import data.
  74. * @param {Readable} zippedGROWIDataStream
  75. * @returns {void}
  76. */
  77. receive(zippedGROWIDataStream: Readable): Promise<void>
  78. }
  79. const generateAxiosRequestConfigWithTransferKey = (tk: TransferKey, additionalHeaders: {[key: string]: string} = {}) => {
  80. const { appUrl, key } = tk;
  81. return {
  82. baseURL: appUrl.origin,
  83. headers: {
  84. ...additionalHeaders,
  85. [X_GROWI_TRANSFER_KEY_HEADER_NAME]: key,
  86. },
  87. maxBodyLength: Infinity,
  88. };
  89. };
  90. /**
  91. * generate GROWIInfo
  92. * @param crowi Crowi instance
  93. * @returns
  94. */
  95. const generateGROWIInfo = (crowi: any): IDataGROWIInfo => {
  96. // TODO: add attachment file limit, storage total limit
  97. const { configManager } = crowi;
  98. const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
  99. const version = crowi.version;
  100. const attachmentInfo = {
  101. type: configManager.getConfig('crowi', 'app:fileUploadType'),
  102. bucket: undefined,
  103. customEndpoint: undefined, // for S3
  104. uploadNamespace: undefined, // for GCS
  105. };
  106. // put storage location info to check storage identification
  107. switch (attachmentInfo.type) {
  108. case 'aws':
  109. attachmentInfo.bucket = configManager.getConfig('crowi', 'aws:s3Bucket');
  110. attachmentInfo.customEndpoint = configManager.getConfig('crowi', 'aws:s3CustomEndpoint');
  111. break;
  112. case 'gcs':
  113. attachmentInfo.bucket = configManager.getConfig('crowi', 'gcs:bucket');
  114. attachmentInfo.uploadNamespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
  115. break;
  116. default:
  117. }
  118. return { userUpperLimit, version, attachmentInfo };
  119. };
  120. export class G2GTransferPusherService implements Pusher {
  121. crowi: any;
  122. // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  123. constructor(crowi: any) {
  124. this.crowi = crowi;
  125. }
  126. public async askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo> {
  127. // axios get
  128. let toGROWIInfo: IDataGROWIInfo;
  129. try {
  130. const res = await axios.get('/_api/v3/g2g-transfer/growi-info', generateAxiosRequestConfigWithTransferKey(tk));
  131. toGROWIInfo = res.data.growiInfo;
  132. }
  133. catch (err) {
  134. logger.error(err);
  135. throw new G2GTransferError('Failed to retreive growi info.', G2GTransferErrorCode.FAILED_TO_RETREIVE_GROWI_INFO);
  136. }
  137. return toGROWIInfo;
  138. }
  139. public async canTransfer(toGROWIInfo: IDataGROWIInfo): Promise<boolean> {
  140. const configManager = this.crowi.configManager;
  141. const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
  142. const version = this.crowi.version;
  143. if (version !== toGROWIInfo.version) {
  144. return false;
  145. }
  146. if ((userUpperLimit ?? Infinity) < (toGROWIInfo.userUpperLimit ?? 0)) {
  147. return false;
  148. }
  149. return true;
  150. }
  151. public async transferAttachments(tk: TransferKey): Promise<void> {
  152. const BATCH_SIZE = 100;
  153. const { appUrl, key } = tk;
  154. const { fileUploadService } = this.crowi;
  155. const Attachment = this.crowi.model('Attachment');
  156. // batch get
  157. const attachmentsCursor = await Attachment.find().cursor();
  158. const batchStream = createBatchStream(BATCH_SIZE);
  159. for await (const attachmentBatch of attachmentsCursor.pipe(batchStream)) {
  160. for await (const attachment of attachmentBatch) {
  161. logger.debug(`processing attachment: ${attachment}`);
  162. let fileStream;
  163. try {
  164. // get read stream of each attachment
  165. fileStream = await fileUploadService.findDeliveryFile(attachment);
  166. }
  167. catch (err) {
  168. logger.warn(`Error occured when getting Attachment(ID=${attachment.id}), skipping: `, err);
  169. continue;
  170. }
  171. // TODO: get attachmentLists from destination GROWI to avoid transferring files that the dest GROWI has
  172. // TODO: refresh transfer key per 1 hour
  173. // post each attachment file data to receiver
  174. try {
  175. await this.doTransferAttachment(tk, attachment, fileStream);
  176. }
  177. catch (errs) {
  178. logger.error(`Error occured when uploading attachment(ID=${attachment.id})`, errs);
  179. if (!Array.isArray(errs)) {
  180. // TODO: socker.emit(failed_to_transfer);
  181. return;
  182. }
  183. const err = errs[0];
  184. logger.error(err);
  185. // TODO: socker.emit(failed_to_transfer);
  186. return;
  187. }
  188. }
  189. }
  190. }
  191. public async startTransfer(tk: TransferKey, user: any, toGROWIInfo: IDataGROWIInfo, collections: string[], optionsMap: any): Promise<void> {
  192. let zipFileStream: ReadStream;
  193. try {
  194. const shouldEmit = false;
  195. const zipFileStat = await this.crowi.exportService.export(collections, shouldEmit);
  196. const zipFilePath = zipFileStat.zipFilePath;
  197. zipFileStream = createReadStream(zipFilePath);
  198. }
  199. catch (err) {
  200. logger.error(err);
  201. throw err;
  202. }
  203. if (toGROWIInfo.attachmentInfo.type === 'none') {
  204. try {
  205. const targetConfigKeys = [
  206. 'app:fileUploadType',
  207. 'app:useOnlyEnvVarForFileUploadType',
  208. 'aws:referenceFileWithRelayMode',
  209. 'aws:lifetimeSecForTemporaryUrl',
  210. 'gcs:apiKeyJsonPath',
  211. 'gcs:bucket',
  212. 'gcs:uploadNamespace',
  213. 'gcs:referenceFileWithRelayMode',
  214. 'gcs:useOnlyEnvVarsForSomeOptions',
  215. ];
  216. const updateConfigs = Object.fromEntries(targetConfigKeys.map((key) => {
  217. return [key, this.crowi.configManager.getConfig('crowi', key)];
  218. }));
  219. await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', updateConfigs);
  220. }
  221. catch (err) {
  222. logger.error(err);
  223. throw err;
  224. }
  225. }
  226. // Send a zip file to other growi via axios
  227. try {
  228. // Use FormData to immitate browser's form data object
  229. const form = new FormData();
  230. const appTitle = this.crowi.appService.getAppTitle();
  231. form.append('transferDataZipFile', zipFileStream, `${appTitle}-${Date.now}.growi.zip`);
  232. form.append('collections', JSON.stringify(collections));
  233. form.append('optionsMap', JSON.stringify(optionsMap));
  234. form.append('operatorUserId', user._id.toString());
  235. await rawAxios.post('/_api/v3/g2g-transfer/', form, generateAxiosRequestConfigWithTransferKey(tk, form.getHeaders()));
  236. }
  237. catch (errs) {
  238. logger.error(errs);
  239. if (!Array.isArray(errs)) {
  240. // TODO: socker.emit(failed_to_transfer);
  241. throw errs;
  242. }
  243. const err = errs[0];
  244. logger.error(err);
  245. // TODO: socker.emit(failed_to_transfer);
  246. throw errs;
  247. }
  248. try {
  249. await this.transferAttachments(tk);
  250. }
  251. catch (err) {
  252. logger.error(err);
  253. throw err;
  254. }
  255. return;
  256. }
  257. /**
  258. * transfer attachment to destination GROWI
  259. * @param tk Transfer key
  260. * @param attachment Attachment model instance
  261. * @param fileStream Attachment data(loaded from storage)
  262. */
  263. private async doTransferAttachment(tk: TransferKey, attachment, fileStream: Readable) {
  264. // Use FormData to immitate browser's form data object
  265. const form = new FormData();
  266. form.append('content', fileStream, attachment.fileName);
  267. form.append('attachmentMetadata', JSON.stringify(attachment));
  268. await rawAxios.post('/_api/v3/g2g-transfer/attachment', form, generateAxiosRequestConfigWithTransferKey(tk, form.getHeaders()));
  269. }
  270. }
  271. export class G2GTransferReceiverService implements Receiver {
  272. crowi: any;
  273. constructor(crowi: any) {
  274. this.crowi = crowi;
  275. }
  276. public async validateTransferKey(transferKeyString: string): Promise<void> {
  277. // Parse to tk
  278. // Find active tkd
  279. return;
  280. }
  281. public async answerGROWIInfo(): Promise<IDataGROWIInfo> {
  282. return generateGROWIInfo(this.crowi);
  283. }
  284. public async createTransferKey(appSiteUrl: URL): Promise<string> {
  285. const uuid = new MongooseTypes.ObjectId().toString();
  286. // Generate transfer key string
  287. let transferKeyString: string;
  288. try {
  289. transferKeyString = TransferKey.generateKeyString(uuid, appSiteUrl);
  290. }
  291. catch (err) {
  292. logger.error(err);
  293. throw err;
  294. }
  295. // Save TransferKey document
  296. let tkd;
  297. try {
  298. tkd = await TransferKeyModel.create({ _id: uuid, keyString: transferKeyString, key: uuid });
  299. }
  300. catch (err) {
  301. logger.error(err);
  302. throw err;
  303. }
  304. return tkd.keyString;
  305. }
  306. public async receive(zipfile: Readable): Promise<void> {
  307. // Import data
  308. // Call onCompleteTransfer when finished
  309. return;
  310. }
  311. /**
  312. *
  313. * @param content Pushed attachment data from source GROWI
  314. * @param attachmentMap Map-ped Attachment instance
  315. * @returns
  316. */
  317. public async receiveAttachment(content: Readable, attachmentMap): Promise<void> {
  318. const { fileUploadService } = this.crowi;
  319. return fileUploadService.uploadFile(content, attachmentMap);
  320. }
  321. /**
  322. * Sync DB, etc.
  323. * @returns {Promise<void>}
  324. */
  325. private async onCompleteTransfer(): Promise<void> { return }
  326. }