aws.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import {
  2. S3Client,
  3. HeadObjectCommand,
  4. GetObjectCommand,
  5. DeleteObjectsCommand,
  6. PutObjectCommand,
  7. DeleteObjectCommand,
  8. ListObjectsCommand,
  9. type GetObjectCommandInput,
  10. } from '@aws-sdk/client-s3';
  11. import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
  12. import type { Response } from 'express';
  13. import urljoin from 'url-join';
  14. import type { IAttachmentDocument } from '~/server/models';
  15. import loggerFactory from '~/utils/logger';
  16. import { configManager } from '../config-manager';
  17. import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
  18. const logger = loggerFactory('growi:service:fileUploaderAws');
  19. /**
  20. * File metadata in storage
  21. * TODO: mv this to "./uploader"
  22. */
  23. interface FileMeta {
  24. name: string;
  25. size: number;
  26. }
  27. type AwsCredential = {
  28. accessKeyId: string,
  29. secretAccessKey: string
  30. }
  31. type AwsConfig = {
  32. credentials: AwsCredential,
  33. region: string,
  34. endpoint: string,
  35. bucket: string,
  36. forcePathStyle?: boolean
  37. }
  38. // TODO: rewrite this module to be a type-safe implementation
  39. class AwsFileUploader extends AbstractFileUploader {
  40. /**
  41. * @inheritdoc
  42. */
  43. override isValidUploadSettings(): boolean {
  44. throw new Error('Method not implemented.');
  45. }
  46. /**
  47. * @inheritdoc
  48. */
  49. override listFiles() {
  50. throw new Error('Method not implemented.');
  51. }
  52. /**
  53. * @inheritdoc
  54. */
  55. override saveFile(param: SaveFileParam) {
  56. throw new Error('Method not implemented.');
  57. }
  58. /**
  59. * @inheritdoc
  60. */
  61. override deleteFiles() {
  62. throw new Error('Method not implemented.');
  63. }
  64. /**
  65. * @inheritdoc
  66. */
  67. override respond(res: Response, attachment: IAttachmentDocument): void {
  68. throw new Error('Method not implemented.');
  69. }
  70. /**
  71. * @inheritdoc
  72. */
  73. override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
  74. throw new Error('Method not implemented.');
  75. }
  76. }
  77. module.exports = (crowi) => {
  78. const lib = new AwsFileUploader(crowi);
  79. const getAwsConfig = (): AwsConfig => {
  80. return {
  81. credentials: {
  82. accessKeyId: configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
  83. secretAccessKey: configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
  84. },
  85. region: configManager.getConfig('crowi', 'aws:s3Region'),
  86. endpoint: configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
  87. bucket: configManager.getConfig('crowi', 'aws:s3Bucket'),
  88. forcePathStyle: configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null, // s3ForcePathStyle renamed to forcePathStyle in v3
  89. };
  90. };
  91. const S3Factory = (): S3Client => {
  92. const config = getAwsConfig();
  93. return new S3Client(config);
  94. };
  95. const getFilePathOnStorage = (attachment) => {
  96. if (attachment.filePath != null) {
  97. return attachment.filePath;
  98. }
  99. const dirName = (attachment.page != null)
  100. ? 'attachment'
  101. : 'user';
  102. const filePath = urljoin(dirName, attachment.fileName);
  103. return filePath;
  104. };
  105. const isFileExists = async(s3: S3Client, params) => {
  106. try {
  107. await s3.send(new HeadObjectCommand(params));
  108. }
  109. catch (err) {
  110. if (err != null && err.code === 'NotFound') {
  111. return false;
  112. }
  113. throw err;
  114. }
  115. return true;
  116. };
  117. lib.isValidUploadSettings = function() {
  118. return configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
  119. && configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
  120. && (
  121. configManager.getConfig('crowi', 'aws:s3Region') != null
  122. || configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null
  123. )
  124. && configManager.getConfig('crowi', 'aws:s3Bucket') != null;
  125. };
  126. lib.canRespond = function() {
  127. return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
  128. };
  129. lib.respond = async function(res, attachment: IAttachmentDocument) {
  130. if (!lib.getIsUploadable()) {
  131. throw new Error('AWS is not configured.');
  132. }
  133. const temporaryUrl = attachment.getValidTemporaryUrl();
  134. if (temporaryUrl != null) {
  135. return res.redirect(temporaryUrl);
  136. }
  137. const s3 = S3Factory();
  138. const awsConfig = getAwsConfig();
  139. const filePath = getFilePathOnStorage(attachment);
  140. const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'aws:lifetimeSecForTemporaryUrl');
  141. // issue signed url (default: expires 120 seconds)
  142. // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
  143. const params: GetObjectCommandInput = {
  144. Bucket: awsConfig.bucket,
  145. Key: filePath,
  146. };
  147. const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
  148. expiresIn: lifetimeSecForTemporaryUrl,
  149. });
  150. res.redirect(signedUrl);
  151. try {
  152. return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
  153. }
  154. catch (err) {
  155. logger.error(err);
  156. }
  157. };
  158. (lib as any).deleteFile = async function(attachment) {
  159. const filePath = getFilePathOnStorage(attachment);
  160. return (lib as any).deleteFileByFilePath(filePath);
  161. };
  162. (lib as any).deleteFiles = async function(attachments) {
  163. if (!lib.getIsUploadable()) {
  164. throw new Error('AWS is not configured.');
  165. }
  166. const s3 = S3Factory();
  167. const awsConfig = getAwsConfig();
  168. const filePaths = attachments.map((attachment) => {
  169. return { Key: getFilePathOnStorage(attachment) };
  170. });
  171. const totalParams = {
  172. Bucket: awsConfig.bucket,
  173. Delete: { Objects: filePaths },
  174. };
  175. return s3.send(new DeleteObjectsCommand(totalParams));
  176. };
  177. (lib as any).deleteFileByFilePath = async function(filePath) {
  178. if (!lib.getIsUploadable()) {
  179. throw new Error('AWS is not configured.');
  180. }
  181. const s3 = S3Factory();
  182. const awsConfig = getAwsConfig();
  183. const params = {
  184. Bucket: awsConfig.bucket,
  185. Key: filePath,
  186. };
  187. // check file exists
  188. const isExists = await isFileExists(s3, params);
  189. if (!isExists) {
  190. logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
  191. return;
  192. }
  193. return s3.send(new DeleteObjectCommand(params));
  194. };
  195. (lib as any).uploadAttachment = async function(fileStream, attachment) {
  196. if (!lib.getIsUploadable()) {
  197. throw new Error('AWS is not configured.');
  198. }
  199. logger.debug(`File uploading: fileName=${attachment.fileName}`);
  200. const s3 = S3Factory();
  201. const awsConfig = getAwsConfig();
  202. const filePath = getFilePathOnStorage(attachment);
  203. const params = {
  204. Bucket: awsConfig.bucket,
  205. ContentType: attachment.fileFormat,
  206. Key: filePath,
  207. Body: fileStream,
  208. ACL: 'public-read',
  209. };
  210. return s3.send(new PutObjectCommand(params));
  211. };
  212. lib.saveFile = async function({ filePath, contentType, data }) {
  213. const s3 = S3Factory();
  214. const awsConfig = getAwsConfig();
  215. const params = {
  216. Bucket: awsConfig.bucket,
  217. ContentType: contentType,
  218. Key: filePath,
  219. Body: data,
  220. ACL: 'public-read',
  221. };
  222. return s3.send(new PutObjectCommand(params));
  223. };
  224. lib.findDeliveryFile = async function(attachment) {
  225. if (!lib.getIsReadable()) {
  226. throw new Error('AWS is not configured.');
  227. }
  228. const s3 = S3Factory();
  229. const awsConfig = getAwsConfig();
  230. const filePath = getFilePathOnStorage(attachment);
  231. const params = {
  232. Bucket: awsConfig.bucket,
  233. Key: filePath,
  234. };
  235. // check file exists
  236. const isExists = await isFileExists(s3, params);
  237. if (!isExists) {
  238. throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
  239. }
  240. try {
  241. const body = (await s3.send(new GetObjectCommand(params))).Body;
  242. if (body == null) {
  243. throw new Error(`S3 returned null for the Attachment (${filePath})`);
  244. }
  245. // eslint-disable-next-line no-nested-ternary
  246. return 'stream' in body
  247. ? body.stream() // get stream from Blob
  248. : !('read' in body)
  249. ? body as unknown as NodeJS.ReadableStream // cast force
  250. : body;
  251. }
  252. catch (err) {
  253. logger.error(err);
  254. throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
  255. }
  256. };
  257. (lib as any).checkLimit = async function(uploadFileSize) {
  258. const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
  259. const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
  260. return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
  261. };
  262. /**
  263. * List files in storage
  264. */
  265. (lib as any).listFiles = async function() {
  266. if (!lib.getIsReadable()) {
  267. throw new Error('AWS is not configured.');
  268. }
  269. const files: FileMeta[] = [];
  270. const s3 = S3Factory();
  271. const awsConfig = getAwsConfig();
  272. const params = {
  273. Bucket: awsConfig.bucket,
  274. };
  275. let shouldContinue = true;
  276. let nextMarker: string | undefined;
  277. // handle pagination
  278. while (shouldContinue) {
  279. // eslint-disable-next-line no-await-in-loop
  280. const { Contents = [], IsTruncated, NextMarker } = await s3.send(new ListObjectsCommand({
  281. ...params,
  282. Marker: nextMarker,
  283. }));
  284. files.push(...(
  285. Contents.map(({ Key, Size }) => ({
  286. name: Key as string,
  287. size: Size as number,
  288. }))
  289. ));
  290. if (!IsTruncated) {
  291. shouldContinue = false;
  292. nextMarker = undefined;
  293. }
  294. else {
  295. nextMarker = NextMarker;
  296. }
  297. }
  298. return files;
  299. };
  300. return lib;
  301. };