g2g-transfer.ts 9.8 KB

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