index.js 7.7 KB

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