index.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  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. const {
  20. STSClient,
  21. GetFederationTokenCommand,
  22. } = require('@aws-sdk/client-sts')
  23. const policy = {
  24. Version: '2012-10-17',
  25. Statement: [
  26. {
  27. Effect: 'Allow',
  28. Action: [
  29. 's3:PutObject',
  30. ],
  31. Resource: [
  32. `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}/*`,
  33. `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}`,
  34. ],
  35. },
  36. ],
  37. }
  38. /**
  39. * @type {S3Client}
  40. */
  41. let s3Client
  42. /**
  43. * @type {STSClient}
  44. */
  45. let stsClient
  46. const expiresIn = 900 // Define how long until a S3 signature expires.
  47. function getS3Client () {
  48. s3Client ??= new S3Client({
  49. region: process.env.COMPANION_AWS_REGION,
  50. credentials : {
  51. accessKeyId: process.env.COMPANION_AWS_KEY,
  52. secretAccessKey: process.env.COMPANION_AWS_SECRET,
  53. },
  54. })
  55. return s3Client
  56. }
  57. function getSTSClient () {
  58. stsClient ??= new STSClient({
  59. region: process.env.COMPANION_AWS_REGION,
  60. credentials : {
  61. accessKeyId: process.env.COMPANION_AWS_KEY,
  62. secretAccessKey: process.env.COMPANION_AWS_SECRET,
  63. },
  64. })
  65. return stsClient
  66. }
  67. app.use(bodyParser.urlencoded({ extended: true }), bodyParser.json())
  68. app.get('/', (req, res) => {
  69. const htmlPath = path.join(__dirname, 'public', 'index.html')
  70. res.sendFile(htmlPath)
  71. })
  72. app.get('/drag', (req, res) => {
  73. const htmlPath = path.join(__dirname, 'public', 'drag.html')
  74. res.sendFile(htmlPath)
  75. })
  76. app.get('/sts', (req, res, next) => {
  77. getSTSClient().send(new GetFederationTokenCommand({
  78. Name: '123user',
  79. // The duration, in seconds, of the role session. The value specified
  80. // can range from 900 seconds (15 minutes) up to the maximum session
  81. // duration set for the role.
  82. DurationSeconds: expiresIn,
  83. Policy: JSON.stringify(policy),
  84. })).then(response => {
  85. // Test creating multipart upload from the server — it works
  86. // createMultipartUploadYo(response)
  87. res.setHeader('Access-Control-Allow-Origin', '*')
  88. res.setHeader('Cache-Control', `public,max-age=${expiresIn}`)
  89. res.json({
  90. credentials: response.Credentials,
  91. bucket: process.env.COMPANION_AWS_BUCKET,
  92. region: process.env.COMPANION_AWS_REGION,
  93. })
  94. }, next)
  95. })
  96. app.post('/sign-s3', (req, res, next) => {
  97. const Key = `${crypto.randomUUID()}-${req.body.filename}`
  98. const { contentType } = req.body
  99. getSignedUrl(getS3Client(), new PutObjectCommand({
  100. Bucket: process.env.COMPANION_AWS_BUCKET,
  101. Key,
  102. ContentType: contentType,
  103. }), { expiresIn }).then((url) => {
  104. res.setHeader('Access-Control-Allow-Origin', '*')
  105. res.json({
  106. url,
  107. method: 'PUT',
  108. })
  109. res.end()
  110. }, next)
  111. })
  112. // === <S3 Multipart> ===
  113. // You can remove those endpoints if you only want to support the non-multipart uploads.
  114. app.post('/s3/multipart', (req, res, next) => {
  115. const client = getS3Client()
  116. const { type, metadata, filename } = req.body
  117. if (typeof filename !== 'string') {
  118. return res.status(400).json({ error: 's3: content filename must be a string' })
  119. }
  120. if (typeof type !== 'string') {
  121. return res.status(400).json({ error: 's3: content type must be a string' })
  122. }
  123. const Key = `${crypto.randomUUID()}-${filename}`
  124. const params = {
  125. Bucket: process.env.COMPANION_AWS_BUCKET,
  126. Key,
  127. ContentType: type,
  128. Metadata: metadata,
  129. }
  130. const command = new CreateMultipartUploadCommand(params)
  131. return client.send(command, (err, data) => {
  132. if (err) {
  133. next(err)
  134. return
  135. }
  136. res.setHeader('Access-Control-Allow-Origin', '*')
  137. res.json({
  138. key: data.Key,
  139. uploadId: data.UploadId,
  140. })
  141. })
  142. })
  143. function validatePartNumber (partNumber) {
  144. // eslint-disable-next-line no-param-reassign
  145. partNumber = Number(partNumber)
  146. return Number.isInteger(partNumber) && partNumber >= 1 && partNumber <= 10_000
  147. }
  148. app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => {
  149. const { uploadId, partNumber } = req.params
  150. const { key } = req.query
  151. if (!validatePartNumber(partNumber)) {
  152. return res.status(400).json({ error: 's3: the part number must be an integer between 1 and 10000.' })
  153. }
  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. return getSignedUrl(getS3Client(), new UploadPartCommand({
  158. Bucket: process.env.COMPANION_AWS_BUCKET,
  159. Key: key,
  160. UploadId: uploadId,
  161. PartNumber: partNumber,
  162. Body: '',
  163. }), { expiresIn }).then((url) => {
  164. res.setHeader('Access-Control-Allow-Origin', '*')
  165. res.json({ url, expires: expiresIn })
  166. }, next)
  167. })
  168. app.get('/s3/multipart/:uploadId', (req, res, next) => {
  169. const client = getS3Client()
  170. const { uploadId } = req.params
  171. const { key } = req.query
  172. if (typeof key !== 'string') {
  173. res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  174. return
  175. }
  176. const parts = []
  177. function listPartsPage(startsAt = undefined) {
  178. client.send(new ListPartsCommand({
  179. Bucket: process.env.COMPANION_AWS_BUCKET,
  180. Key: key,
  181. UploadId: uploadId,
  182. PartNumberMarker: startsAt,
  183. }), (err, data) => {
  184. if (err) {
  185. next(err)
  186. return
  187. }
  188. parts.push(...data.Parts)
  189. // continue to get list of all uploaded parts until the IsTruncated flag is false
  190. if (data.IsTruncated) {
  191. listPartsPage(data.NextPartNumberMarker)
  192. } else {
  193. res.json(parts)
  194. }
  195. })
  196. }
  197. listPartsPage()
  198. })
  199. function isValidPart (part) {
  200. return part && typeof part === 'object' && Number(part.PartNumber) && typeof part.ETag === 'string'
  201. }
  202. app.post('/s3/multipart/:uploadId/complete', (req, res, next) => {
  203. const client = getS3Client()
  204. const { uploadId } = req.params
  205. const { key } = req.query
  206. const { parts } = req.body
  207. if (typeof key !== 'string') {
  208. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  209. }
  210. if (!Array.isArray(parts) || !parts.every(isValidPart)) {
  211. return res.status(400).json({ error: 's3: `parts` must be an array of {ETag, PartNumber} objects.' })
  212. }
  213. return client.send(new CompleteMultipartUploadCommand({
  214. Bucket: process.env.COMPANION_AWS_BUCKET,
  215. Key: key,
  216. UploadId: uploadId,
  217. MultipartUpload: {
  218. Parts: parts,
  219. },
  220. }), (err, data) => {
  221. if (err) {
  222. next(err)
  223. return
  224. }
  225. res.setHeader('Access-Control-Allow-Origin', '*')
  226. res.json({
  227. location: data.Location,
  228. })
  229. })
  230. })
  231. app.delete('/s3/multipart/:uploadId', (req, res, next) => {
  232. const client = getS3Client()
  233. const { uploadId } = req.params
  234. const { key } = req.query
  235. if (typeof key !== 'string') {
  236. return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
  237. }
  238. return client.send(new AbortMultipartUploadCommand({
  239. Bucket: process.env.COMPANION_AWS_BUCKET,
  240. Key: key,
  241. UploadId: uploadId,
  242. }), (err) => {
  243. if (err) {
  244. next(err)
  245. return
  246. }
  247. res.json({})
  248. })
  249. })
  250. // === </S3 MULTIPART> ===
  251. app.listen(port, () => {
  252. console.log(`Example app listening on port ${port}`)
  253. })