index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. import type { Readable } from 'stream';
  2. import type { GetObjectCommandInput, HeadObjectCommandInput } from '@aws-sdk/client-s3';
  3. import {
  4. S3Client,
  5. HeadObjectCommand,
  6. GetObjectCommand,
  7. DeleteObjectsCommand,
  8. PutObjectCommand,
  9. DeleteObjectCommand,
  10. ListObjectsCommand,
  11. ObjectCannedACL,
  12. AbortMultipartUploadCommand,
  13. } from '@aws-sdk/client-s3';
  14. import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
  15. import urljoin from 'url-join';
  16. import {
  17. AttachmentType, FilePathOnStoragePrefix, ResponseMode, type RespondOptions,
  18. } from '~/server/interfaces/attachment';
  19. import type { IAttachmentDocument } from '~/server/models/attachment';
  20. import loggerFactory from '~/utils/logger';
  21. import { configManager } from '../../config-manager';
  22. import {
  23. AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
  24. } from '../file-uploader';
  25. import { ContentHeaders } from '../utils';
  26. import { AwsMultipartUploader } from './multipart-uploader';
  27. const logger = loggerFactory('growi:service:fileUploaderAws');
  28. /**
  29. * File metadata in storage
  30. * TODO: mv this to "./uploader"
  31. */
  32. interface FileMeta {
  33. name: string;
  34. size: number;
  35. }
  36. const isFileExists = async(s3: S3Client, params: HeadObjectCommandInput) => {
  37. try {
  38. await s3.send(new HeadObjectCommand(params));
  39. }
  40. catch (err) {
  41. if (err != null && err.code === 'NotFound') {
  42. return false;
  43. }
  44. throw err;
  45. }
  46. return true;
  47. };
  48. const ObjectCannedACLs = [
  49. ObjectCannedACL.authenticated_read,
  50. ObjectCannedACL.aws_exec_read,
  51. ObjectCannedACL.bucket_owner_full_control,
  52. ObjectCannedACL.bucket_owner_read,
  53. ObjectCannedACL.private,
  54. ObjectCannedACL.public_read,
  55. ObjectCannedACL.public_read_write,
  56. ];
  57. const isValidObjectCannedACL = (acl: string | null): acl is ObjectCannedACL => {
  58. return ObjectCannedACLs.includes(acl as ObjectCannedACL);
  59. };
  60. /**
  61. * @see: https://dev.growi.org/5d091f611fe336003eec5bfd
  62. * @returns ObjectCannedACL
  63. */
  64. const getS3PutObjectCannedAcl = (): ObjectCannedACL | undefined => {
  65. const s3ObjectCannedACL = configManager.getConfig('crowi', 'aws:s3ObjectCannedACL');
  66. if (isValidObjectCannedACL(s3ObjectCannedACL)) {
  67. return s3ObjectCannedACL;
  68. }
  69. return undefined;
  70. };
  71. const getS3Bucket = (): string | undefined => {
  72. return configManager.getConfig('crowi', 'aws:s3Bucket') ?? undefined; // return undefined when getConfig() returns null
  73. };
  74. const S3Factory = (): S3Client => {
  75. return new S3Client({
  76. credentials: {
  77. accessKeyId: configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
  78. secretAccessKey: configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
  79. },
  80. region: configManager.getConfig('crowi', 'aws:s3Region'),
  81. endpoint: configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
  82. forcePathStyle: configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null, // s3ForcePathStyle renamed to forcePathStyle in v3
  83. });
  84. };
  85. const getFilePathOnStorage = (attachment: IAttachmentDocument) => {
  86. if (attachment.filePath != null) { // DEPRECATED: remains for backward compatibility for v3.3.x or below
  87. return attachment.filePath;
  88. }
  89. let dirName: string;
  90. if (attachment.attachmentType === AttachmentType.PAGE_BULK_EXPORT) {
  91. dirName = FilePathOnStoragePrefix.pageBulkExport;
  92. }
  93. else if (attachment.page != null) {
  94. dirName = FilePathOnStoragePrefix.attachment;
  95. }
  96. else {
  97. dirName = FilePathOnStoragePrefix.user;
  98. }
  99. const filePath = urljoin(dirName, attachment.fileName);
  100. return filePath;
  101. };
  102. // TODO: rewrite this module to be a type-safe implementation
  103. class AwsFileUploader extends AbstractFileUploader {
  104. /**
  105. * @inheritdoc
  106. */
  107. override isValidUploadSettings(): boolean {
  108. throw new Error('Method not implemented.');
  109. }
  110. /**
  111. * @inheritdoc
  112. */
  113. override listFiles() {
  114. throw new Error('Method not implemented.');
  115. }
  116. /**
  117. * @inheritdoc
  118. */
  119. override saveFile(param: SaveFileParam) {
  120. throw new Error('Method not implemented.');
  121. }
  122. /**
  123. * @inheritdoc
  124. */
  125. override deleteFiles() {
  126. throw new Error('Method not implemented.');
  127. }
  128. /**
  129. * @inheritdoc
  130. */
  131. override determineResponseMode() {
  132. return configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode')
  133. ? ResponseMode.RELAY
  134. : ResponseMode.REDIRECT;
  135. }
  136. /**
  137. * @inheritdoc
  138. */
  139. override async uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void> {
  140. if (!this.getIsUploadable()) {
  141. throw new Error('AWS is not configured.');
  142. }
  143. logger.debug(`File uploading: fileName=${attachment.fileName}`);
  144. const s3 = S3Factory();
  145. const filePath = getFilePathOnStorage(attachment);
  146. const contentHeaders = new ContentHeaders(attachment);
  147. await s3.send(new PutObjectCommand({
  148. Bucket: getS3Bucket(),
  149. Key: filePath,
  150. Body: readable,
  151. ACL: getS3PutObjectCannedAcl(),
  152. // put type and the file name for reference information when uploading
  153. ContentType: contentHeaders.contentType?.value.toString(),
  154. ContentDisposition: contentHeaders.contentDisposition?.value.toString(),
  155. }));
  156. }
  157. /**
  158. * @inheritdoc
  159. */
  160. override respond(): void {
  161. throw new Error('AwsFileUploader does not support ResponseMode.DELEGATE.');
  162. }
  163. /**
  164. * @inheritdoc
  165. */
  166. override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
  167. if (!this.getIsReadable()) {
  168. throw new Error('AWS is not configured.');
  169. }
  170. const s3 = S3Factory();
  171. const filePath = getFilePathOnStorage(attachment);
  172. const params = {
  173. Bucket: getS3Bucket(),
  174. Key: filePath,
  175. };
  176. // check file exists
  177. const isExists = await isFileExists(s3, params);
  178. if (!isExists) {
  179. throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
  180. }
  181. try {
  182. const body = (await s3.send(new GetObjectCommand(params))).Body;
  183. if (body == null) {
  184. throw new Error(`S3 returned null for the Attachment (${filePath})`);
  185. }
  186. // eslint-disable-next-line no-nested-ternary
  187. return 'stream' in body
  188. ? body.stream() as unknown as NodeJS.ReadableStream // get stream from Blob and cast force
  189. : body as unknown as NodeJS.ReadableStream; // cast force
  190. }
  191. catch (err) {
  192. logger.error(err);
  193. throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
  194. }
  195. }
  196. /**
  197. * @inheritDoc
  198. */
  199. override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
  200. if (!this.getIsUploadable()) {
  201. throw new Error('AWS is not configured.');
  202. }
  203. const s3 = S3Factory();
  204. const filePath = getFilePathOnStorage(attachment);
  205. const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'aws:lifetimeSecForTemporaryUrl');
  206. // issue signed url (default: expires 120 seconds)
  207. // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
  208. const isDownload = opts?.download ?? false;
  209. const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
  210. const params: GetObjectCommandInput = {
  211. Bucket: getS3Bucket(),
  212. Key: filePath,
  213. ResponseContentType: contentHeaders.contentType?.value.toString(),
  214. ResponseContentDisposition: contentHeaders.contentDisposition?.value.toString(),
  215. };
  216. const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
  217. expiresIn: lifetimeSecForTemporaryUrl,
  218. });
  219. return {
  220. url: signedUrl,
  221. lifetimeSec: lifetimeSecForTemporaryUrl,
  222. };
  223. }
  224. override createMultipartUploader(uploadKey: string, maxPartSize: number) {
  225. const s3 = S3Factory();
  226. return new AwsMultipartUploader(s3, getS3Bucket(), uploadKey, maxPartSize);
  227. }
  228. override async abortPreviousMultipartUpload(uploadKey: string, uploadId: string) {
  229. try {
  230. await S3Factory().send(new AbortMultipartUploadCommand({
  231. Bucket: getS3Bucket(),
  232. Key: uploadKey,
  233. UploadId: uploadId,
  234. }));
  235. }
  236. catch (e) {
  237. // allow duplicate abort requests to ensure abortion
  238. if (e.response?.status !== 404) {
  239. throw e;
  240. }
  241. }
  242. }
  243. }
  244. module.exports = (crowi) => {
  245. const lib = new AwsFileUploader(crowi);
  246. lib.isValidUploadSettings = function() {
  247. return configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
  248. && configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
  249. && (
  250. configManager.getConfig('crowi', 'aws:s3Region') != null
  251. || configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null
  252. )
  253. && configManager.getConfig('crowi', 'aws:s3Bucket') != null;
  254. };
  255. (lib as any).deleteFile = async function(attachment) {
  256. const filePath = getFilePathOnStorage(attachment);
  257. return (lib as any).deleteFileByFilePath(filePath);
  258. };
  259. (lib as any).deleteFiles = async function(attachments) {
  260. if (!lib.getIsUploadable()) {
  261. throw new Error('AWS is not configured.');
  262. }
  263. const s3 = S3Factory();
  264. const filePaths = attachments.map((attachment) => {
  265. return { Key: getFilePathOnStorage(attachment) };
  266. });
  267. const totalParams = {
  268. Bucket: getS3Bucket(),
  269. Delete: { Objects: filePaths },
  270. };
  271. return s3.send(new DeleteObjectsCommand(totalParams));
  272. };
  273. (lib as any).deleteFileByFilePath = async function(filePath) {
  274. if (!lib.getIsUploadable()) {
  275. throw new Error('AWS is not configured.');
  276. }
  277. const s3 = S3Factory();
  278. const params = {
  279. Bucket: getS3Bucket(),
  280. Key: filePath,
  281. };
  282. // check file exists
  283. const isExists = await isFileExists(s3, params);
  284. if (!isExists) {
  285. logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
  286. return;
  287. }
  288. return s3.send(new DeleteObjectCommand(params));
  289. };
  290. lib.saveFile = async function({ filePath, contentType, data }) {
  291. const s3 = S3Factory();
  292. return s3.send(new PutObjectCommand({
  293. Bucket: getS3Bucket(),
  294. ContentType: contentType,
  295. Key: filePath,
  296. Body: data,
  297. ACL: getS3PutObjectCannedAcl(),
  298. }));
  299. };
  300. (lib as any).checkLimit = async function(uploadFileSize) {
  301. const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
  302. const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
  303. return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
  304. };
  305. /**
  306. * List files in storage
  307. */
  308. (lib as any).listFiles = async function() {
  309. if (!lib.getIsReadable()) {
  310. throw new Error('AWS is not configured.');
  311. }
  312. const files: FileMeta[] = [];
  313. const s3 = S3Factory();
  314. const params = {
  315. Bucket: getS3Bucket(),
  316. };
  317. let shouldContinue = true;
  318. let nextMarker: string | undefined;
  319. // handle pagination
  320. while (shouldContinue) {
  321. // eslint-disable-next-line no-await-in-loop
  322. const { Contents = [], IsTruncated, NextMarker } = await s3.send(new ListObjectsCommand({
  323. ...params,
  324. Marker: nextMarker,
  325. }));
  326. files.push(...(
  327. Contents.map(({ Key, Size }) => ({
  328. name: Key as string,
  329. size: Size as number,
  330. }))
  331. ));
  332. if (!IsTruncated) {
  333. shouldContinue = false;
  334. nextMarker = undefined;
  335. }
  336. else {
  337. nextMarker = NextMarker;
  338. }
  339. }
  340. return files;
  341. };
  342. return lib;
  343. };