index.js 10 KB

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