index.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. 'use strict'
  2. const path = require('node:path')
  3. const crypto = require('node:crypto')
  4. require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') })
  5. const express = require('express')
  6. const app = express()
  7. const port = process.env.PORT ?? 8080
  8. const bodyParser = require('body-parser')
  9. const {
  10. S3Client,
  11. AbortMultipartUploadCommand,
  12. CompleteMultipartUploadCommand,
  13. CreateMultipartUploadCommand,
  14. ListPartsCommand,
  15. PutObjectCommand,
  16. UploadPartCommand,
  17. } = require('@aws-sdk/client-s3')
  18. const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
  19. /**
  20. * @type {S3Client}
  21. */
  22. let s3Client
  23. const expiresIn = 900 // Define how long until a S3 signature expires.
  24. function getS3Client () {
  25. s3Client ??= new S3Client({
  26. region: process.env.COMPANION_AWS_REGION,
  27. credentials : {
  28. accessKeyId: process.env.COMPANION_AWS_KEY,
  29. secretAccessKey: process.env.COMPANION_AWS_SECRET,
  30. },
  31. })
  32. return s3Client
  33. }
  34. app.use(bodyParser.json())
  35. app.get('/', (req, res) => {
  36. const htmlPath = path.join(__dirname, 'public', 'index.html')
  37. res.sendFile(htmlPath)
  38. })
  39. app.get('/drag', (req, res) => {
  40. const htmlPath = path.join(__dirname, 'public', 'drag.html')
  41. res.sendFile(htmlPath)
  42. })
  43. app.post('/sign-s3', (req, res, next) => {
  44. const Key = `${crypto.randomUUID()}-${req.body.filename}`
  45. const { contentType } = req.body
  46. getSignedUrl(getS3Client(), new PutObjectCommand({
  47. Bucket: process.env.COMPANION_AWS_BUCKET,
  48. Key,
  49. ContentType: contentType,
  50. }), { expiresIn }).then((url) => {
  51. res.setHeader('Access-Control-Allow-Origin', '*')
  52. res.json({
  53. url,
  54. method: 'PUT',
  55. })
  56. res.end()
  57. }, next)
  58. })
  59. // === <S3 Multipart> ===
  60. // You can remove those endpoints if you only want to support the non-multipart uploads.
  61. app.post('/s3/multipart', (req, res, next) => {
  62. const client = getS3Client()
  63. const { type, metadata, filename } = req.body
  64. if (typeof filename !== 'string') {
  65. return res.status(400).json({ error: 's3: content filename must be a string' })
  66. }
  67. if (typeof type !== 'string') {
  68. return res.status(400).json({ error: 's3: content type must be a string' })
  69. }
  70. const Key = `${crypto.randomUUID()}-${filename}`
  71. const params = {
  72. Bucket: process.env.COMPANION_AWS_BUCKET,
  73. Key,
  74. ContentType: type,
  75. Metadata: metadata,
  76. }
  77. const command = new CreateMultipartUploadCommand(params)
  78. return client.send(command, (err, data) => {
  79. if (err) {
  80. next(err)
  81. return
  82. }
  83. res.setHeader('Access-Control-Allow-Origin', '*')
  84. res.json({
  85. key: data.Key,
  86. uploadId: data.UploadId,
  87. })
  88. })
  89. })
  90. function validatePartNumber (partNumber) {
  91. // eslint-disable-next-line no-param-reassign
  92. partNumber = Number(partNumber)
  93. return Number.isInteger(partNumber) && partNumber >= 1 && partNumber <= 10_000
  94. }
  95. app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => {
  96. const { uploadId, partNumber } = req.params
  97. const { key } = req.query
  98. if (!validatePartNumber(partNumber)) {
  99. return res.status(400).json({ error: 's3: the part number must be an integer between 1 and 10000.' })
  100. }
  101. if (typeof key !== 'string') {
  102. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  103. }
  104. return getSignedUrl(getS3Client(), new UploadPartCommand({
  105. Bucket: process.env.COMPANION_AWS_BUCKET,
  106. Key: key,
  107. UploadId: uploadId,
  108. PartNumber: partNumber,
  109. Body: '',
  110. }), { expiresIn }).then((url) => {
  111. res.setHeader('Access-Control-Allow-Origin', '*')
  112. res.json({ url, expires: expiresIn })
  113. }, next)
  114. })
  115. app.get('/s3/multipart/:uploadId', (req, res, next) => {
  116. const client = getS3Client()
  117. const { uploadId } = req.params
  118. const { key } = req.query
  119. if (typeof key !== 'string') {
  120. res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  121. return
  122. }
  123. const parts = []
  124. function listPartsPage (startAt) {
  125. client.send(new ListPartsCommand({
  126. Bucket: process.env.COMPANION_AWS_BUCKET,
  127. Key: key,
  128. UploadId: uploadId,
  129. PartNumberMarker: startAt,
  130. }), (err, data) => {
  131. if (err) {
  132. next(err)
  133. return
  134. }
  135. parts.push(...data.Parts)
  136. if (data.IsTruncated) {
  137. // Get the next page.
  138. listPartsPage(data.NextPartNumberMarker)
  139. } else {
  140. res.json(parts)
  141. }
  142. })
  143. }
  144. listPartsPage(0)
  145. })
  146. function isValidPart (part) {
  147. return part && typeof part === 'object' && Number(part.PartNumber) && typeof part.ETag === 'string'
  148. }
  149. app.post('/s3/multipart/:uploadId/complete', (req, res, next) => {
  150. const client = getS3Client()
  151. const { uploadId } = req.params
  152. const { key } = req.query
  153. const { parts } = req.body
  154. if (typeof key !== 'string') {
  155. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  156. }
  157. if (!Array.isArray(parts) || !parts.every(isValidPart)) {
  158. return res.status(400).json({ error: 's3: `parts` must be an array of {ETag, PartNumber} objects.' })
  159. }
  160. return client.send(new CompleteMultipartUploadCommand({
  161. Bucket: process.env.COMPANION_AWS_BUCKET,
  162. Key: key,
  163. UploadId: uploadId,
  164. MultipartUpload: {
  165. Parts: parts,
  166. },
  167. }), (err, data) => {
  168. if (err) {
  169. next(err)
  170. return
  171. }
  172. res.setHeader('Access-Control-Allow-Origin', '*')
  173. res.json({
  174. location: data.Location,
  175. })
  176. })
  177. })
  178. app.delete('/s3/multipart/:uploadId', (req, res, next) => {
  179. const client = getS3Client()
  180. const { uploadId } = req.params
  181. const { key } = req.query
  182. if (typeof key !== 'string') {
  183. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  184. }
  185. return client.send(new AbortMultipartUploadCommand({
  186. Bucket: process.env.COMPANION_AWS_BUCKET,
  187. Key: key,
  188. UploadId: uploadId,
  189. }), (err) => {
  190. if (err) {
  191. next(err)
  192. return
  193. }
  194. res.json({})
  195. })
  196. })
  197. // === </S3 MULTIPART> ===
  198. app.listen(port, () => {
  199. console.log(`Example app listening on port ${port}`)
  200. })