s3.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. const router = require('express').Router
  2. module.exports = function s3 (config) {
  3. if (typeof config.acl !== 'string') {
  4. throw new TypeError('s3: The `acl` option must be a string')
  5. }
  6. if (typeof config.getKey !== 'function') {
  7. throw new TypeError('s3: The `getKey` option must be a function')
  8. }
  9. /**
  10. * Get upload paramaters for a simple direct upload.
  11. *
  12. * Expected query parameters:
  13. * - filename - The name of the file, given to the `config.getKey`
  14. * option to determine the object key name in the S3 bucket.
  15. * - type - The MIME type of the file.
  16. * - metadata - Key/value pairs configuring S3 metadata. Both must be ASCII-safe.
  17. * Query parameters are formatted like `metadata[name]=value`.
  18. *
  19. * Response JSON:
  20. * - method - The HTTP method to use to upload.
  21. * - url - The URL to upload to.
  22. * - fields - Form fields to send along.
  23. */
  24. function getUploadParameters (req, res, next) {
  25. // @ts-ignore The `companion` property is added by middleware before reaching here.
  26. const client = req.companion.s3Client
  27. if (!client || typeof config.bucket !== 'string') {
  28. return res.status(400).json({ error: 'This Companion server does not support uploading to S3' })
  29. }
  30. const metadata = req.query.metadata || {}
  31. const key = config.getKey(req, req.query.filename, metadata)
  32. if (typeof key !== 'string') {
  33. return res.status(500).json({ error: 'S3 uploads are misconfigured: filename returned from `getKey` must be a string' })
  34. }
  35. const fields = {
  36. acl: config.acl,
  37. key,
  38. success_action_status: '201',
  39. 'content-type': req.query.type,
  40. }
  41. Object.keys(metadata).forEach((key) => {
  42. fields[`x-amz-meta-${key}`] = metadata[key]
  43. })
  44. client.createPresignedPost({
  45. Bucket: config.bucket,
  46. Expires: config.expires,
  47. Fields: fields,
  48. Conditions: config.conditions,
  49. }, (err, data) => {
  50. if (err) {
  51. next(err)
  52. return
  53. }
  54. res.json({
  55. method: 'post',
  56. url: data.url,
  57. fields: data.fields,
  58. })
  59. })
  60. }
  61. /**
  62. * Create an S3 multipart upload. With this, files can be uploaded in chunks of 5MB+ each.
  63. *
  64. * Expected JSON body:
  65. * - filename - The name of the file, given to the `config.getKey`
  66. * option to determine the object key name in the S3 bucket.
  67. * - type - The MIME type of the file.
  68. * - metadata - An object with the key/value pairs to set as metadata.
  69. * Keys and values must be ASCII-safe for S3.
  70. *
  71. * Response JSON:
  72. * - key - The object key in the S3 bucket.
  73. * - uploadId - The ID of this multipart upload, to be used in later requests.
  74. */
  75. function createMultipartUpload (req, res, next) {
  76. // @ts-ignore The `companion` property is added by middleware before reaching here.
  77. const client = req.companion.s3Client
  78. const key = config.getKey(req, req.body.filename, req.body.metadata || {})
  79. const { type, metadata } = req.body
  80. if (typeof key !== 'string') {
  81. return res.status(500).json({ error: 's3: filename returned from `getKey` must be a string' })
  82. }
  83. if (typeof type !== 'string') {
  84. return res.status(400).json({ error: 's3: content type must be a string' })
  85. }
  86. client.createMultipartUpload({
  87. Bucket: config.bucket,
  88. Key: key,
  89. ACL: config.acl,
  90. ContentType: type,
  91. Metadata: metadata,
  92. }, (err, data) => {
  93. if (err) {
  94. next(err)
  95. return
  96. }
  97. res.json({
  98. key: data.Key,
  99. uploadId: data.UploadId,
  100. })
  101. })
  102. }
  103. /**
  104. * List parts that have been fully uploaded so far.
  105. *
  106. * Expected URL parameters:
  107. * - uploadId - The uploadId returned from `createMultipartUpload`.
  108. * Expected query parameters:
  109. * - key - The object key in the S3 bucket.
  110. * Response JSON:
  111. * - An array of objects representing parts:
  112. * - PartNumber - the index of this part.
  113. * - ETag - a hash of this part's contents, used to refer to it.
  114. * - Size - size of this part.
  115. */
  116. function getUploadedParts (req, res, next) {
  117. // @ts-ignore The `companion` property is added by middleware before reaching here.
  118. const client = req.companion.s3Client
  119. const { uploadId } = req.params
  120. const { key } = req.query
  121. if (typeof key !== 'string') {
  122. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  123. }
  124. let parts = []
  125. listPartsPage(0)
  126. function listPartsPage (startAt) {
  127. client.listParts({
  128. Bucket: config.bucket,
  129. Key: key,
  130. UploadId: uploadId,
  131. PartNumberMarker: startAt,
  132. }, (err, data) => {
  133. if (err) {
  134. next(err)
  135. return
  136. }
  137. parts = parts.concat(data.Parts)
  138. if (data.IsTruncated) {
  139. // Get the next page.
  140. listPartsPage(data.NextPartNumberMarker)
  141. } else {
  142. done()
  143. }
  144. })
  145. }
  146. function done () {
  147. res.json(parts)
  148. }
  149. }
  150. /**
  151. * Get parameters for uploading one part.
  152. *
  153. * Expected URL parameters:
  154. * - uploadId - The uploadId returned from `createMultipartUpload`.
  155. * - partNumber - This part's index in the file (1-10000).
  156. * Expected query parameters:
  157. * - key - The object key in the S3 bucket.
  158. * Response JSON:
  159. * - url - The URL to upload to, including signed query parameters.
  160. */
  161. function signPartUpload (req, res, next) {
  162. // @ts-ignore The `companion` property is added by middleware before reaching here.
  163. const client = req.companion.s3Client
  164. const { uploadId, partNumber } = req.params
  165. const { key } = req.query
  166. if (typeof key !== 'string') {
  167. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  168. }
  169. if (!parseInt(partNumber, 10)) {
  170. return res.status(400).json({ error: 's3: the part number must be a number between 1 and 10000.' })
  171. }
  172. client.getSignedUrl('uploadPart', {
  173. Bucket: config.bucket,
  174. Key: key,
  175. UploadId: uploadId,
  176. PartNumber: partNumber,
  177. Body: '',
  178. Expires: config.expires,
  179. }, (err, url) => {
  180. if (err) {
  181. next(err)
  182. return
  183. }
  184. res.json({ url })
  185. })
  186. }
  187. /**
  188. * Get parameters for uploading a batch of parts.
  189. *
  190. * Expected URL parameters:
  191. * - uploadId - The uploadId returned from `createMultipartUpload`.
  192. * Expected query parameters:
  193. * - key - The object key in the S3 bucket.
  194. * - partNumbers - A comma separated list of part numbers representing
  195. * indecies in the file (1-10000).
  196. * Response JSON:
  197. * - presignedUrls - The URLs to upload to, including signed query parameters,
  198. * in an object mapped to part numbers.
  199. */
  200. function batchSignPartsUpload (req, res, next) {
  201. // @ts-ignore The `companion` property is added by middleware before reaching here.
  202. const client = req.companion.s3Client
  203. const { uploadId } = req.params
  204. const { key, partNumbers } = req.query
  205. if (typeof key !== 'string') {
  206. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  207. }
  208. if (typeof partNumbers !== 'string') {
  209. return 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"' })
  210. }
  211. const partNumbersArray = partNumbers.split(',')
  212. partNumbersArray.forEach((partNumber) => {
  213. if (!parseInt(partNumber, 10)) {
  214. return res.status(400).json({ error: 's3: the part numbers must be a number between 1 and 10000.' })
  215. }
  216. })
  217. Promise.all(
  218. partNumbersArray.map((partNumber) => {
  219. return client.getSignedUrlPromise('uploadPart', {
  220. Bucket: config.bucket,
  221. Key: key,
  222. UploadId: uploadId,
  223. PartNumber: partNumber,
  224. Body: '',
  225. Expires: config.expires,
  226. })
  227. })
  228. ).then((urls) => {
  229. const presignedUrls = Object.create(null)
  230. for (let index = 0; index < partNumbersArray.length; index++) {
  231. presignedUrls[partNumbersArray[index]] = urls[index]
  232. }
  233. res.json({ presignedUrls })
  234. }).catch((err) => {
  235. next(err)
  236. })
  237. }
  238. /**
  239. * Abort a multipart upload, deleting already uploaded parts.
  240. *
  241. * Expected URL parameters:
  242. * - uploadId - The uploadId returned from `createMultipartUpload`.
  243. * Expected query parameters:
  244. * - key - The object key in the S3 bucket.
  245. * Response JSON:
  246. * Empty.
  247. */
  248. function abortMultipartUpload (req, res, next) {
  249. // @ts-ignore The `companion` property is added by middleware before reaching here.
  250. const client = req.companion.s3Client
  251. const { uploadId } = req.params
  252. const { key } = req.query
  253. if (typeof key !== 'string') {
  254. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  255. }
  256. client.abortMultipartUpload({
  257. Bucket: config.bucket,
  258. Key: key,
  259. UploadId: uploadId,
  260. }, (err) => {
  261. if (err) {
  262. next(err)
  263. return
  264. }
  265. res.json({})
  266. })
  267. }
  268. /**
  269. * Complete a multipart upload, combining all the parts into a single object in the S3 bucket.
  270. *
  271. * Expected URL parameters:
  272. * - uploadId - The uploadId returned from `createMultipartUpload`.
  273. * Expected query parameters:
  274. * - key - The object key in the S3 bucket.
  275. * Expected JSON body:
  276. * - parts - An array of parts, see the `getUploadedParts` response JSON.
  277. * Response JSON:
  278. * - location - The full URL to the object in the S3 bucket.
  279. */
  280. function completeMultipartUpload (req, res, next) {
  281. // @ts-ignore The `companion` property is added by middleware before reaching here.
  282. const client = req.companion.s3Client
  283. const { uploadId } = req.params
  284. const { key } = req.query
  285. const { parts } = req.body
  286. if (typeof key !== 'string') {
  287. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  288. }
  289. if (!Array.isArray(parts) || !parts.every(isValidPart)) {
  290. return res.status(400).json({ error: 's3: `parts` must be an array of {ETag, PartNumber} objects.' })
  291. }
  292. client.completeMultipartUpload({
  293. Bucket: config.bucket,
  294. Key: key,
  295. UploadId: uploadId,
  296. MultipartUpload: {
  297. Parts: parts,
  298. },
  299. }, (err, data) => {
  300. if (err) {
  301. next(err)
  302. return
  303. }
  304. res.json({
  305. location: data.Location,
  306. })
  307. })
  308. }
  309. return router()
  310. .get('/params', getUploadParameters)
  311. .post('/multipart', createMultipartUpload)
  312. .get('/multipart/:uploadId', getUploadedParts)
  313. .get('/multipart/:uploadId/batch', batchSignPartsUpload)
  314. .get('/multipart/:uploadId/:partNumber', signPartUpload)
  315. .post('/multipart/:uploadId/complete', completeMultipartUpload)
  316. .delete('/multipart/:uploadId', abortMultipartUpload)
  317. }
  318. function isValidPart (part) {
  319. return part && typeof part === 'object' && typeof part.PartNumber === 'number' && typeof part.ETag === 'string'
  320. }