s3.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. const express = require('express')
  2. const {
  3. CreateMultipartUploadCommand,
  4. ListPartsCommand,
  5. UploadPartCommand,
  6. AbortMultipartUploadCommand,
  7. CompleteMultipartUploadCommand,
  8. } = require('@aws-sdk/client-s3')
  9. const {
  10. STSClient,
  11. GetFederationTokenCommand,
  12. } = require('@aws-sdk/client-sts')
  13. const { createPresignedPost } = require('@aws-sdk/s3-presigned-post')
  14. const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
  15. const { rfc2047EncodeMetadata, getBucket } = require('../helpers/utils')
  16. module.exports = function s3 (config) {
  17. if (typeof config.acl !== 'string' && config.acl != null) {
  18. throw new TypeError('s3: The `acl` option must be a string or null')
  19. }
  20. if (typeof config.getKey !== 'function') {
  21. throw new TypeError('s3: The `getKey` option must be a function')
  22. }
  23. function getS3Client (req, res, createPresignedPostMode = false) {
  24. /**
  25. * @type {import('@aws-sdk/client-s3').S3Client}
  26. */
  27. const client = createPresignedPostMode ? req.companion.s3ClientCreatePresignedPost : req.companion.s3Client
  28. if (!client) res.status(400).json({ error: 'This Companion server does not support uploading to S3' })
  29. return client
  30. }
  31. /**
  32. * Get upload paramaters for a simple direct upload.
  33. *
  34. * Expected query parameters:
  35. * - filename - The name of the file, given to the `config.getKey`
  36. * option to determine the object key name in the S3 bucket.
  37. * - type - The MIME type of the file.
  38. * - metadata - Key/value pairs configuring S3 metadata. Both must be ASCII-safe.
  39. * Query parameters are formatted like `metadata[name]=value`.
  40. *
  41. * Response JSON:
  42. * - method - The HTTP method to use to upload.
  43. * - url - The URL to upload to.
  44. * - fields - Form fields to send along.
  45. */
  46. function getUploadParameters (req, res, next) {
  47. const client = getS3Client(req, res)
  48. if (!client) return
  49. const { metadata = {}, filename } = req.query
  50. const bucket = getBucket({ bucketOrFn: config.bucket, req, filename, metadata })
  51. const key = config.getKey({ req, filename, metadata })
  52. if (typeof key !== 'string') {
  53. res.status(500).json({ error: 'S3 uploads are misconfigured: filename returned from `getKey` must be a string' })
  54. return
  55. }
  56. const fields = {
  57. success_action_status: '201',
  58. 'content-type': req.query.type,
  59. }
  60. if (config.acl != null) fields.acl = config.acl
  61. Object.keys(metadata).forEach((metadataKey) => {
  62. fields[`x-amz-meta-${metadataKey}`] = metadata[metadataKey]
  63. })
  64. createPresignedPost(client, {
  65. Bucket: bucket,
  66. Expires: config.expires,
  67. Fields: fields,
  68. Conditions: config.conditions,
  69. Key: key,
  70. }).then(data => {
  71. res.json({
  72. method: 'POST',
  73. url: data.url,
  74. fields: data.fields,
  75. expires: config.expires,
  76. })
  77. }, next)
  78. }
  79. /**
  80. * Create an S3 multipart upload. With this, files can be uploaded in chunks of 5MB+ each.
  81. *
  82. * Expected JSON body:
  83. * - filename - The name of the file, given to the `config.getKey`
  84. * option to determine the object key name in the S3 bucket.
  85. * - type - The MIME type of the file.
  86. * - metadata - An object with the key/value pairs to set as metadata.
  87. * Keys and values must be ASCII-safe for S3.
  88. *
  89. * Response JSON:
  90. * - key - The object key in the S3 bucket.
  91. * - uploadId - The ID of this multipart upload, to be used in later requests.
  92. */
  93. function createMultipartUpload (req, res, next) {
  94. const client = getS3Client(req, res)
  95. if (!client) return
  96. const { type, metadata = {}, filename } = req.body
  97. const key = config.getKey({ req, filename, metadata })
  98. const bucket = getBucket({ bucketOrFn: config.bucket, req, filename, metadata })
  99. if (typeof key !== 'string') {
  100. res.status(500).json({ error: 's3: filename returned from `getKey` must be a string' })
  101. return
  102. }
  103. if (typeof type !== 'string') {
  104. res.status(400).json({ error: 's3: content type must be a string' })
  105. return
  106. }
  107. const params = {
  108. Bucket: bucket,
  109. Key: key,
  110. ContentType: type,
  111. Metadata: rfc2047EncodeMetadata(metadata),
  112. }
  113. if (config.acl != null) params.ACL = config.acl
  114. client.send(new CreateMultipartUploadCommand(params)).then((data) => {
  115. res.json({
  116. key: data.Key,
  117. uploadId: data.UploadId,
  118. })
  119. }, next)
  120. }
  121. /**
  122. * List parts that have been fully uploaded so far.
  123. *
  124. * Expected URL parameters:
  125. * - uploadId - The uploadId returned from `createMultipartUpload`.
  126. * Expected query parameters:
  127. * - key - The object key in the S3 bucket.
  128. * Response JSON:
  129. * - An array of objects representing parts:
  130. * - PartNumber - the index of this part.
  131. * - ETag - a hash of this part's contents, used to refer to it.
  132. * - Size - size of this part.
  133. */
  134. function getUploadedParts (req, res, next) {
  135. const client = getS3Client(req, res)
  136. if (!client) return
  137. const { uploadId } = req.params
  138. const { key } = req.query
  139. if (typeof key !== 'string') {
  140. res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  141. return
  142. }
  143. const bucket = getBucket({ bucketOrFn: config.bucket, req })
  144. const parts = []
  145. function listPartsPage (startAt) {
  146. client.send(new ListPartsCommand({
  147. Bucket: bucket,
  148. Key: key,
  149. UploadId: uploadId,
  150. PartNumberMarker: startAt,
  151. })).then(({ Parts, IsTruncated, NextPartNumberMarker }) => {
  152. if (Parts) parts.push(...Parts)
  153. if (IsTruncated) {
  154. // Get the next page.
  155. listPartsPage(NextPartNumberMarker)
  156. } else {
  157. res.json(parts)
  158. }
  159. }, next)
  160. }
  161. listPartsPage()
  162. }
  163. /**
  164. * Get parameters for uploading one part.
  165. *
  166. * Expected URL parameters:
  167. * - uploadId - The uploadId returned from `createMultipartUpload`.
  168. * - partNumber - This part's index in the file (1-10000).
  169. * Expected query parameters:
  170. * - key - The object key in the S3 bucket.
  171. * Response JSON:
  172. * - url - The URL to upload to, including signed query parameters.
  173. */
  174. function signPartUpload (req, res, next) {
  175. const client = getS3Client(req, res)
  176. if (!client) return
  177. const { uploadId, partNumber } = req.params
  178. const { key } = req.query
  179. if (typeof key !== 'string') {
  180. res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  181. return
  182. }
  183. if (!parseInt(partNumber, 10)) {
  184. res.status(400).json({ error: 's3: the part number must be a number between 1 and 10000.' })
  185. return
  186. }
  187. const bucket = getBucket({ bucketOrFn: config.bucket, req })
  188. getSignedUrl(client, new UploadPartCommand({
  189. Bucket: bucket,
  190. Key: key,
  191. UploadId: uploadId,
  192. PartNumber: partNumber,
  193. Body: '',
  194. }), { expiresIn: config.expires }).then(url => {
  195. res.json({ url, expires: config.expires })
  196. }, next)
  197. }
  198. /**
  199. * Get parameters for uploading a batch of parts.
  200. *
  201. * Expected URL parameters:
  202. * - uploadId - The uploadId returned from `createMultipartUpload`.
  203. * Expected query parameters:
  204. * - key - The object key in the S3 bucket.
  205. * - partNumbers - A comma separated list of part numbers representing
  206. * indecies in the file (1-10000).
  207. * Response JSON:
  208. * - presignedUrls - The URLs to upload to, including signed query parameters,
  209. * in an object mapped to part numbers.
  210. */
  211. function batchSignPartsUpload (req, res, next) {
  212. const client = getS3Client(req, res)
  213. if (!client) return
  214. const { uploadId } = req.params
  215. const { key, partNumbers } = req.query
  216. if (typeof key !== 'string') {
  217. res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  218. return
  219. }
  220. if (typeof partNumbers !== 'string') {
  221. res.status(400).json({ error: 's3: the part numbers must be passed as a comma separated query parameter. For example: "?partNumbers=4,6,7,21"' })
  222. return
  223. }
  224. const partNumbersArray = partNumbers.split(',')
  225. if (!partNumbersArray.every((partNumber) => parseInt(partNumber, 10))) {
  226. res.status(400).json({ error: 's3: the part numbers must be a number between 1 and 10000.' })
  227. return
  228. }
  229. const bucket = getBucket({ bucketOrFn: config.bucket, req })
  230. Promise.all(
  231. partNumbersArray.map((partNumber) => {
  232. return getSignedUrl(client, new UploadPartCommand({
  233. Bucket: bucket,
  234. Key: key,
  235. UploadId: uploadId,
  236. PartNumber: Number(partNumber),
  237. Body: '',
  238. }), { expiresIn: config.expires })
  239. }),
  240. ).then((urls) => {
  241. const presignedUrls = Object.create(null)
  242. for (let index = 0; index < partNumbersArray.length; index++) {
  243. presignedUrls[partNumbersArray[index]] = urls[index]
  244. }
  245. res.json({ presignedUrls })
  246. }).catch(next)
  247. }
  248. /**
  249. * Abort a multipart upload, deleting already uploaded parts.
  250. *
  251. * Expected URL parameters:
  252. * - uploadId - The uploadId returned from `createMultipartUpload`.
  253. * Expected query parameters:
  254. * - key - The object key in the S3 bucket.
  255. * Response JSON:
  256. * Empty.
  257. */
  258. function abortMultipartUpload (req, res, next) {
  259. const client = getS3Client(req, res)
  260. if (!client) return
  261. const { uploadId } = req.params
  262. const { key } = req.query
  263. if (typeof key !== 'string') {
  264. res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  265. return
  266. }
  267. const bucket = getBucket({ bucketOrFn: config.bucket, req })
  268. client.send(new AbortMultipartUploadCommand({
  269. Bucket: bucket,
  270. Key: key,
  271. UploadId: uploadId,
  272. })).then(() => res.json({}), next)
  273. }
  274. /**
  275. * Complete a multipart upload, combining all the parts into a single object in the S3 bucket.
  276. *
  277. * Expected URL parameters:
  278. * - uploadId - The uploadId returned from `createMultipartUpload`.
  279. * Expected query parameters:
  280. * - key - The object key in the S3 bucket.
  281. * Expected JSON body:
  282. * - parts - An array of parts, see the `getUploadedParts` response JSON.
  283. * Response JSON:
  284. * - location - The full URL to the object in the S3 bucket.
  285. */
  286. function completeMultipartUpload (req, res, next) {
  287. const client = getS3Client(req, res)
  288. if (!client) return
  289. const { uploadId } = req.params
  290. const { key } = req.query
  291. const { parts } = req.body
  292. if (typeof key !== 'string') {
  293. res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  294. return
  295. }
  296. if (
  297. !Array.isArray(parts)
  298. || !parts.every(part => typeof part === 'object' && typeof part?.PartNumber === 'number' && typeof part.ETag === 'string')
  299. ) {
  300. res.status(400).json({ error: 's3: `parts` must be an array of {ETag, PartNumber} objects.' })
  301. return
  302. }
  303. const bucket = getBucket({ bucketOrFn: config.bucket, req })
  304. client.send(new CompleteMultipartUploadCommand({
  305. Bucket: bucket,
  306. Key: key,
  307. UploadId: uploadId,
  308. MultipartUpload: {
  309. Parts: parts,
  310. },
  311. })).then(data => {
  312. res.json({
  313. location: data.Location,
  314. })
  315. }, next)
  316. }
  317. const policy = {
  318. Version: '2012-10-17', // latest at the time of writing
  319. Statement: [
  320. {
  321. Effect: 'Allow',
  322. Action: [
  323. 's3:PutObject',
  324. ],
  325. Resource: [
  326. `arn:aws:s3:::${config.bucket}/*`,
  327. `arn:aws:s3:::${config.bucket}`,
  328. ],
  329. },
  330. ],
  331. }
  332. let stsClient
  333. function getSTSClient () {
  334. if (stsClient == null) {
  335. stsClient = new STSClient({
  336. region: config.region,
  337. credentials : {
  338. accessKeyId: config.key,
  339. secretAccessKey: config.secret,
  340. },
  341. })
  342. }
  343. return stsClient
  344. }
  345. /**
  346. * Create STS credentials with the permission for sending PutObject/UploadPart to the bucket.
  347. *
  348. * Clients should cache the response and re-use it until they can reasonably
  349. * expect uploads to complete before the token expires. To this effect, the
  350. * Cache-Control header is set to invalidate the cache 5 minutes before the
  351. * token expires.
  352. *
  353. * Response JSON:
  354. * - credentials: the credentials including the SessionToken.
  355. * - bucket: the S3 bucket name.
  356. * - region: the region where that bucket is stored.
  357. */
  358. function getTemporarySecurityCredentials (req, res, next) {
  359. getSTSClient().send(new GetFederationTokenCommand({
  360. // Name of the federated user. The name is used as an identifier for the
  361. // temporary security credentials (such as Bob). For example, you can
  362. // reference the federated user name in a resource-based policy, such as
  363. // in an Amazon S3 bucket policy.
  364. // Companion is configured by default as an unprotected public endpoint,
  365. // if you implement your own custom endpoint with user authentication you
  366. // should probably use different names for each of your users.
  367. Name: 'companion',
  368. // The duration, in seconds, of the role session. The value specified
  369. // can range from 900 seconds (15 minutes) up to the maximum session
  370. // duration set for the role.
  371. DurationSeconds: config.expires,
  372. Policy: JSON.stringify(policy),
  373. })).then(response => {
  374. // This is a public unprotected endpoint.
  375. // If you implement your own custom endpoint with user authentication you
  376. // should probably use `private` instead of `public`.
  377. res.setHeader('Cache-Control', `public,max-age=${config.expires - 300}`) // 300s is 5min.
  378. res.json({
  379. credentials: response.Credentials,
  380. bucket: config.bucket,
  381. region: config.region,
  382. })
  383. }, next)
  384. }
  385. if (config.bucket == null) {
  386. return express.Router() // empty router because s3 is not enabled
  387. }
  388. return express.Router()
  389. .get('/sts', getTemporarySecurityCredentials)
  390. .get('/params', getUploadParameters)
  391. .post('/multipart', express.json(), createMultipartUpload)
  392. .get('/multipart/:uploadId', getUploadedParts)
  393. .get('/multipart/:uploadId/batch', batchSignPartsUpload)
  394. .get('/multipart/:uploadId/:partNumber', signPartUpload)
  395. // limit 1mb because maybe large upload with a lot of parts, see https://github.com/transloadit/uppy/issues/1945
  396. .post('/multipart/:uploadId/complete', express.json({ limit: '1mb' }), completeMultipartUpload)
  397. .delete('/multipart/:uploadId', abortMultipartUpload)
  398. }