Skip to content

Commit

Permalink
feat(media): add REST API upload support to the media package
Browse files Browse the repository at this point in the history
  • Loading branch information
Frantz Kati committed Dec 12, 2020
1 parent 825c92d commit a496f3b
Show file tree
Hide file tree
Showing 15 changed files with 504 additions and 108 deletions.
1 change: 1 addition & 0 deletions packages/common/src/fields/OneToOne.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class OneToOne extends RelationshipField {

export const oneToOne = (name: string, databaseField?: string) =>
new OneToOne(name, databaseField)

export const hasOne = oneToOne

export default oneToOne
1 change: 1 addition & 0 deletions packages/core/Tensei.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export class Tensei implements TenseiContract {
request.currentCtx = () => this.ctx
request.mailer = this.ctx.mailer
request.config = this.ctx
request.storage = this.ctx.storage

next()
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/core.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ declare global {
[key: string]: ResourceContract
}
mailer: Mail
storage: Config['storage']
currentCtx: () => Config
scripts: Asset[]
styles: Asset[]
Expand Down
1 change: 1 addition & 0 deletions packages/media/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build/
11 changes: 10 additions & 1 deletion packages/media/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@
"test": "jest --verbose --runInBand --forceExit"
},
"dependencies": {
"@tensei/common": "^0.4.4"
"@tensei/common": "^0.4.4",
"@types/busboy": "^0.2.3",
"@types/fs-capacitor": "^2.0.0",
"@types/http-errors": "^1.8.0",
"@types/object-path": "^0.11.0",
"busboy": "^0.3.1",
"fs-capacitor": "^6.2.0",
"http-errors": "^1.8.0",
"isobject": "^4.0.0",
"object-path": "^0.11.5"
},
"config": {
"commitizen": {
Expand Down
244 changes: 244 additions & 0 deletions packages/media/src/helpers/process-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import Busboy from 'busboy'
import Crypto from 'crypto'
import createError from 'http-errors'
import { WriteStream } from 'fs-capacitor'
import { Request, Response } from 'express'
import { DataPayload, ApiContext } from '@tensei/common'
import { MediaLibraryPluginConfig, UploadFile } from '../types'

import { mediaResource } from '../resources'

const ignoreStream = (stream: NodeJS.ReadableStream) => {
stream.on('error', () => {})

stream.resume()
}

export const process = (
request: Request,
response: Response,
config: MediaLibraryPluginConfig
) => {
return new Promise((resolve, reject) => {
const parser = new Busboy({
headers: request.headers,
limits: {
fieldSize: config.maxFieldSize,
fields: 2, // Only operations and map.
fileSize: config.maxFileSize,
files: config.maxFiles
}
})

let exitError: Error | undefined
let uploads: any[] = []
let body: DataPayload = {}
let released: boolean | undefined
let currentStream: NodeJS.ReadableStream | undefined

const exit = (error: Error) => {
if (exitError) return
exitError = error

reject(exitError)

// @ts-ignore
if (parser?.destroy) {
// @ts-ignore
parser.destroy()
}

if (currentStream) {
// @ts-ignore
if (currentStream.destroy) {
// @ts-ignore
currentStream.destroy(exitError)
}
}

request.unpipe(parser)

// With a sufficiently large request body, subsequent events in the same
// event frame cause the stream to pause after the parser is destroyed. To
// ensure that the request resumes, the call to .resume() is scheduled for
// later in the event loop.
setImmediate(() => {
request.resume()
})
}

const release = () => {
if (released) return
released = true

uploads.forEach(upload => upload.file?.capacitor?.release())
}

const abort = () => {
exit(
createError(
499,
'Request disconnected during file upload stream parsing.'
)
)
}

parser.on('field', (fieldname, value, fieldnameTruncated) => {
if (fieldnameTruncated) {
return exit(
createError(
413,
`The ‘${fieldname}’ multipart field value exceeds the ${config.maxFieldSize} byte size limit.`
)
)
}

body[fieldname] = value
})

parser.on('file', (fieldName, stream, filename, encoding, mimetype) => {
if (exitError) {
ignoreStream(stream)

return
}

let fileError: Error | undefined

currentStream = stream
stream.on('end', () => {
currentStream = undefined
})

const capacitor = new WriteStream()

capacitor.on('error', () => {
stream.unpipe()
stream.resume()
})

stream.on('limit', () => {
fileError = createError(
413,
`File truncated as it exceeds the ${config.maxFileSize} byte size limit.`
)
stream.unpipe()
capacitor.destroy(fileError)
})

stream.on('error', error => {
fileError = error
stream.unpipe()
capacitor.destroy(exitError)
})

const file = {
filename,
mimetype,
encoding,
fieldName,
createReadStream(name?: string) {
const error = fileError || (released ? exitError : null)

if (error) throw error

return capacitor.createReadStream(name)
}
}

Object.defineProperty(file, 'capacitor', { value: capacitor })

stream.pipe(capacitor)
uploads.push(file)
})

parser.once('filesLimit', () =>
exit(
createError(
413,
`${config.maxFiles} max file uploads exceeded.`
)
)
)

parser.once('finish', () => {
request.unpipe(parser)
request.resume()

resolve({
files: uploads,
...body
})
})

parser.once('error', exit)

response.once('finish', release)
response.once('close', release)

request.once('close', abort)
request.once('end', () => {
request.removeListener('close', abort)
})

request.pipe(parser)
})
}

export const handle = async (
ctx: ApiContext,
config: MediaLibraryPluginConfig
) => {
let files = ((await Promise.all(ctx.body?.files)) as UploadFile[]).map(
file => {
const [, extension] = file.filename.split('.')
const hash = Crypto.randomBytes(72).toString('hex')

let file_path: string = ctx.body.path || '/'

file_path = file_path.startsWith('/') ? file_path : `/${file_path}`
file_path = file_path.endsWith('/') ? file_path : `${file_path}/`

return {
...file,
hash,
extension,
path: file_path,
storage_filename: `${file_path}${hash}.${extension}`
}
}
)

await Promise.all(
files.map(file =>
ctx.storage
.disk(config.disk)
.put(`${file.storage_filename}`, file.createReadStream())
)
)

const storedFiles = await Promise.all(
files.map(file => ctx.storage.disk().getStat(file.storage_filename))
)

files = files.map((file, index) => ({
...file,
size: storedFiles[index].size
}))

const entities = files.map(file =>
ctx.manager.create(mediaResource().data.pascalCaseName, {
size: file.size,
hash: file.hash,
path: file.path,
disk: config.disk,
mime_type: file.mimetype,
extension: file.extension,
original_filename: file.filename
})
)

await ctx.manager.persistAndFlush(entities)

return entities
}
36 changes: 26 additions & 10 deletions packages/media/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { snakeCase } from 'change-case'
import { graphqlUploadExpress } from 'graphql-upload'
import { plugin, belongsTo, hasMany } from '@tensei/common'
import { plugin, belongsTo, hasMany, hasOne } from '@tensei/common'

import { routes } from './routes'
import { queries } from './queries'
import { typeDefs } from './type-defs'
import { mediaResource } from './resources'
Expand All @@ -10,8 +12,10 @@ class MediaLibrary {
private config: MediaLibraryPluginConfig = {
disk: '',
maxFiles: 10,
path: 'files',
maxFieldSize: 1000000, // 1 MB
maxFileSize: 10000000,
mediaResourceName: 'File'
transformations: []
}

disk(disk: string) {
Expand All @@ -32,11 +36,18 @@ class MediaLibrary {
return this
}

path(path: string) {
this.config.path = path.startsWith('/') ? path.substring(1) : path

return this
}

plugin() {
return plugin('Media Library').register(
({
app,
currentCtx,
extendRoutes,
storageConfig,
extendResources,
extendGraphQlTypeDefs,
Expand All @@ -46,9 +57,7 @@ class MediaLibrary {
this.config.disk = storageConfig.default!
}

const MediaResource = mediaResource(
this.config.mediaResourceName
)
const MediaResource = mediaResource()

const { resources } = currentCtx()

Expand All @@ -70,24 +79,31 @@ class MediaLibrary {

extendGraphQlQueries(queries(this.config))

extendRoutes(routes(this.config))

extendGraphQlTypeDefs([
typeDefs(
MediaResource.data.snakeCaseName,
MediaResource.data.snakeCaseNamePlural
)
])

app.use(
graphqlUploadExpress({
app.use((request, response, next) => {
if (request.path === `/${this.config.path}`) {
return next()
}

return graphqlUploadExpress({
maxFiles: this.config.maxFiles,
maxFileSize: this.config.maxFileSize
})
)
})(request, response, next)
})
}
)
}
}

export const files = (databaseField?: string) => hasMany('File', databaseField)
export const files = (databaseField?: string) => hasMany('File', databaseField ? snakeCase(databaseField) : undefined)
export const file = (databaseField?: string) => hasOne('File', databaseField ? snakeCase(databaseField) : undefined)

export const media = () => new MediaLibrary()
Loading

0 comments on commit a496f3b

Please sign in to comment.