index.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. 'use strict'
  2. const path = require('node:path')
  3. const crypto = require('node:crypto')
  4. const { existsSync } = require('node:fs')
  5. require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') })
  6. const express = require('express')
  7. const app = express()
  8. const port = process.env.PORT ?? 8080
  9. const accessControlAllowOrigin = '*' // You should define the actual domain(s) that are allowed to make requests.
  10. const bodyParser = require('body-parser')
  11. const {
  12. S3Client,
  13. AbortMultipartUploadCommand,
  14. CompleteMultipartUploadCommand,
  15. CreateMultipartUploadCommand,
  16. ListPartsCommand,
  17. PutObjectCommand,
  18. UploadPartCommand,
  19. } = require('@aws-sdk/client-s3')
  20. const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
  21. const { STSClient, GetFederationTokenCommand } = require('@aws-sdk/client-sts')
  22. const policy = {
  23. Version: '2012-10-17',
  24. Statement: [
  25. {
  26. Effect: 'Allow',
  27. Action: ['s3:PutObject'],
  28. Resource: [
  29. `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}/*`,
  30. `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}`,
  31. ],
  32. },
  33. ],
  34. }
  35. /**
  36. * @type {S3Client}
  37. */
  38. let s3Client
  39. /**
  40. * @type {STSClient}
  41. */
  42. let stsClient
  43. const expiresIn = 900 // Define how long until a S3 signature expires.
  44. function getS3Client() {
  45. s3Client ??= new S3Client({
  46. region: process.env.COMPANION_AWS_REGION,
  47. credentials: {
  48. accessKeyId: process.env.COMPANION_AWS_KEY,
  49. secretAccessKey: process.env.COMPANION_AWS_SECRET,
  50. },
  51. forcePathStyle: process.env.COMPANION_AWS_FORCE_PATH_STYLE === 'true',
  52. })
  53. return s3Client
  54. }
  55. function getSTSClient() {
  56. stsClient ??= new STSClient({
  57. region: process.env.COMPANION_AWS_REGION,
  58. credentials: {
  59. accessKeyId: process.env.COMPANION_AWS_KEY,
  60. secretAccessKey: process.env.COMPANION_AWS_SECRET,
  61. },
  62. })
  63. return stsClient
  64. }
  65. app.use(bodyParser.urlencoded({ extended: true }), bodyParser.json())
  66. app.get('/s3/sts', (req, res, next) => {
  67. // Before giving the STS token to the client, you should first check is they
  68. // are authorized to perform that operation, and if the request is legit.
  69. // For the sake of simplification, we skip that check in this example.
  70. getSTSClient()
  71. .send(
  72. new GetFederationTokenCommand({
  73. Name: '123user',
  74. // The duration, in seconds, of the role session. The value specified
  75. // can range from 900 seconds (15 minutes) up to the maximum session
  76. // duration set for the role.
  77. DurationSeconds: expiresIn,
  78. Policy: JSON.stringify(policy),
  79. }),
  80. )
  81. .then((response) => {
  82. // Test creating multipart upload from the server — it works
  83. // createMultipartUploadYo(response)
  84. res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
  85. res.setHeader('Cache-Control', `public,max-age=${expiresIn}`)
  86. res.json({
  87. credentials: response.Credentials,
  88. bucket: process.env.COMPANION_AWS_BUCKET,
  89. region: process.env.COMPANION_AWS_REGION,
  90. })
  91. }, next)
  92. })
  93. const signOnServer = (req, res, next) => {
  94. // Before giving the signature to the user, you should first check is they
  95. // are authorized to perform that operation, and if the request is legit.
  96. // For the sake of simplification, we skip that check in this example.
  97. const Key = `${crypto.randomUUID()}-${req.body.filename}`
  98. const { contentType } = req.body
  99. getSignedUrl(
  100. getS3Client(),
  101. new PutObjectCommand({
  102. Bucket: process.env.COMPANION_AWS_BUCKET,
  103. Key,
  104. ContentType: contentType,
  105. }),
  106. { expiresIn },
  107. ).then((url) => {
  108. res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
  109. res.json({
  110. url,
  111. method: 'PUT',
  112. })
  113. res.end()
  114. }, next)
  115. }
  116. app.get('/s3/params', signOnServer)
  117. app.post('/s3/sign', signOnServer)
  118. // === <S3 Multipart> ===
  119. // You can remove those endpoints if you only want to support the non-multipart uploads.
  120. app.post('/s3/multipart', (req, res, next) => {
  121. const client = getS3Client()
  122. const { type, metadata, filename } = req.body
  123. if (typeof filename !== 'string') {
  124. return res
  125. .status(400)
  126. .json({ error: 's3: content filename must be a string' })
  127. }
  128. if (typeof type !== 'string') {
  129. return res.status(400).json({ error: 's3: content type must be a string' })
  130. }
  131. const Key = `${crypto.randomUUID()}-${filename}`
  132. const params = {
  133. Bucket: process.env.COMPANION_AWS_BUCKET,
  134. Key,
  135. ContentType: type,
  136. Metadata: metadata,
  137. }
  138. const command = new CreateMultipartUploadCommand(params)
  139. return client.send(command, (err, data) => {
  140. if (err) {
  141. next(err)
  142. return
  143. }
  144. res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
  145. res.json({
  146. key: data.Key,
  147. uploadId: data.UploadId,
  148. })
  149. })
  150. })
  151. function validatePartNumber(partNumber) {
  152. // eslint-disable-next-line no-param-reassign
  153. partNumber = Number(partNumber)
  154. return Number.isInteger(partNumber) && partNumber >= 1 && partNumber <= 10_000
  155. }
  156. app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => {
  157. const { uploadId, partNumber } = req.params
  158. const { key } = req.query
  159. if (!validatePartNumber(partNumber)) {
  160. return res
  161. .status(400)
  162. .json({
  163. error: 's3: the part number must be an integer between 1 and 10000.',
  164. })
  165. }
  166. if (typeof key !== 'string') {
  167. return res
  168. .status(400)
  169. .json({
  170. error:
  171. 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"',
  172. })
  173. }
  174. return getSignedUrl(
  175. getS3Client(),
  176. new UploadPartCommand({
  177. Bucket: process.env.COMPANION_AWS_BUCKET,
  178. Key: key,
  179. UploadId: uploadId,
  180. PartNumber: partNumber,
  181. Body: '',
  182. }),
  183. { expiresIn },
  184. ).then((url) => {
  185. res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
  186. res.json({ url, expires: expiresIn })
  187. }, next)
  188. })
  189. app.get('/s3/multipart/:uploadId', (req, res, next) => {
  190. const client = getS3Client()
  191. const { uploadId } = req.params
  192. const { key } = req.query
  193. if (typeof key !== 'string') {
  194. res
  195. .status(400)
  196. .json({
  197. error:
  198. 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"',
  199. })
  200. return
  201. }
  202. const parts = []
  203. function listPartsPage(startsAt = undefined) {
  204. client.send(new ListPartsCommand({
  205. Bucket: process.env.COMPANION_AWS_BUCKET,
  206. Key: key,
  207. UploadId: uploadId,
  208. PartNumberMarker: startsAt,
  209. }), (err, data) => {
  210. if (err) {
  211. next(err)
  212. return
  213. }
  214. parts.push(...data.Parts)
  215. // continue to get list of all uploaded parts until the IsTruncated flag is false
  216. if (data.IsTruncated) {
  217. listPartsPage(data.NextPartNumberMarker)
  218. } else {
  219. res.json(parts)
  220. }
  221. })
  222. }
  223. listPartsPage()
  224. })
  225. function isValidPart(part) {
  226. return (
  227. part &&
  228. typeof part === 'object' &&
  229. Number(part.PartNumber) &&
  230. typeof part.ETag === 'string'
  231. )
  232. }
  233. app.post('/s3/multipart/:uploadId/complete', (req, res, next) => {
  234. const client = getS3Client()
  235. const { uploadId } = req.params
  236. const { key } = req.query
  237. const { parts } = req.body
  238. if (typeof key !== 'string') {
  239. return res
  240. .status(400)
  241. .json({
  242. error:
  243. 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"',
  244. })
  245. }
  246. if (!Array.isArray(parts) || !parts.every(isValidPart)) {
  247. return res
  248. .status(400)
  249. .json({
  250. error: 's3: `parts` must be an array of {ETag, PartNumber} objects.',
  251. })
  252. }
  253. return client.send(
  254. new CompleteMultipartUploadCommand({
  255. Bucket: process.env.COMPANION_AWS_BUCKET,
  256. Key: key,
  257. UploadId: uploadId,
  258. MultipartUpload: {
  259. Parts: parts,
  260. },
  261. }),
  262. (err, data) => {
  263. if (err) {
  264. next(err)
  265. return
  266. }
  267. res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
  268. res.json({
  269. location: data.Location,
  270. })
  271. },
  272. )
  273. })
  274. app.delete('/s3/multipart/:uploadId', (req, res, next) => {
  275. const client = getS3Client()
  276. const { uploadId } = req.params
  277. const { key } = req.query
  278. if (typeof key !== 'string') {
  279. return res
  280. .status(400)
  281. .json({
  282. error:
  283. 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"',
  284. })
  285. }
  286. return client.send(
  287. new AbortMultipartUploadCommand({
  288. Bucket: process.env.COMPANION_AWS_BUCKET,
  289. Key: key,
  290. UploadId: uploadId,
  291. }),
  292. (err) => {
  293. if (err) {
  294. next(err)
  295. return
  296. }
  297. res.json({})
  298. },
  299. )
  300. })
  301. // === </S3 MULTIPART> ===
  302. // === <some plumbing to make the example work> ===
  303. app.get('/', (req, res) => {
  304. res.setHeader('Content-Type', 'text/html')
  305. const htmlPath = path.join(__dirname, 'public', 'index.html')
  306. res.sendFile(htmlPath)
  307. })
  308. app.get('/index.html', (req, res) => {
  309. res.setHeader('Location', '/').sendStatus(308).end()
  310. })
  311. app.get('/withCustomEndpoints.html', (req, res) => {
  312. res.setHeader('Content-Type', 'text/html')
  313. const htmlPath = path.join(__dirname, 'public', 'withCustomEndpoints.html')
  314. res.sendFile(htmlPath)
  315. })
  316. app.get('/uppy.min.mjs', (req, res) => {
  317. res.setHeader('Content-Type', 'text/javascript')
  318. const bundlePath = path.join(
  319. __dirname,
  320. '../..',
  321. 'packages/uppy/dist',
  322. 'uppy.min.mjs',
  323. )
  324. if (existsSync(bundlePath)) {
  325. res.sendFile(bundlePath)
  326. } else {
  327. console.warn(
  328. 'No local JS bundle found, using the CDN as a fallback. Run `corepack yarn build` to make this warning disappear.',
  329. )
  330. res.end(
  331. 'export * from "https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.mjs";\n',
  332. )
  333. }
  334. })
  335. app.get('/uppy.min.css', (req, res) => {
  336. res.setHeader('Content-Type', 'text/css')
  337. const bundlePath = path.join(
  338. __dirname,
  339. '../..',
  340. 'packages/uppy/dist',
  341. 'uppy.min.css',
  342. )
  343. if (existsSync(bundlePath)) {
  344. res.sendFile(bundlePath)
  345. } else {
  346. console.warn(
  347. 'No local CSS bundle found, using the CDN as a fallback. Run `corepack yarn build` to make this warning disappear.',
  348. )
  349. res.end(
  350. '@import "https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.css";\n',
  351. )
  352. }
  353. })
  354. app.listen(port, () => {
  355. console.log(`Example app listening on port ${port}.`)
  356. console.log(`Visit http://localhost:${port}/ on your browser to try it.`)
  357. })
  358. // === </some plumbing to make the example work> ===