Skip to content

Commit

Permalink
feat: file upload middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
krsbx committed Sep 1, 2023
1 parent 7fdc882 commit 7ef0fa1
Show file tree
Hide file tree
Showing 9 changed files with 1,084 additions and 16 deletions.
61 changes: 61 additions & 0 deletions packages/file-upload/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Hono File Upload

This is a File upload middleware library for [Hono](https://github.com/honojs/hono) which can save file to your server disk, Firebase FireStorage, or event AWS S3.

## Install

```
npm install @hono/file-upload
```

## Usage

```ts
import { Hono } from 'hono'
import { HonoFileUpload } from '@hono/file-upload'

const app = new Hono()
const fileUpload = new HonoFileUpload()

// Save to server disk
app.post(
'/upload/disk',
fileUpload.saveToDisk({
// Pass the destination path
destination: __dirname,
}),
(c) => c.text('OK'
)

// Save to firebase firestorage
app.post(
'/upload/firestorage',
fileUpload.saveToFirebase({
// Pass the destination path in the firebase firestorage
destination: '/',
// Pass the firebase-admin storage instance
storage: ...
}),
(c) => c.text('OK'
)

// Save to aws s3
app.post(
'/upload/firestorage',
fileUpload.saveToAws({
// Pass the bucket path in the aws s3
destination: '/',
// Pass the aws s3 instance
storage: ...
}),
(c) => c.text('OK'
)
```
## Author
krsbx <https://github.com/krsbx>
## License
MIT
1 change: 1 addition & 0 deletions packages/file-upload/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../jest.config.js')
31 changes: 31 additions & 0 deletions packages/file-upload/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "file-upload",
"version": "1.0.0",
"description": "File upload middleware",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"repository": "https://github.com/honojs/middleware.git",
"author": "krsbx",
"scripts": {
"build": "tsc",
"prerelease": "yarn build",
"release": "yarn publish"
},
"license": "MIT",
"dependencies": {
"fs-extra": "^11.1.1"
},
"peerDependencies": {
"hono": "3.*"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/supertest": "^2.0.12",
"aws-sdk": "^2.1449.0",
"firebase-admin": "^11.10.1",
"supertest": "^6.3.3"
}
}
3 changes: 3 additions & 0 deletions packages/file-upload/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const DEFAULT_MAX_SIZE = 10 * 1024 * 1024 // 1 MB
export const DEFAULT_MIN_SIZE = 10 * 1000 // 10 KB
export const FORM_MIME_TYPES = ['multipart/form-data', 'application/x-www-form-urlencoded']
140 changes: 140 additions & 0 deletions packages/file-upload/src/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import path from 'path'
import type { Context, Next } from 'hono'
import { FORM_MIME_TYPES } from './constants'
import type {
HonoFileUploadAwsOption,
HonoFileUploadDiskOption,
HonoFileUploadFirebaseOption,
HonoFileUploadOption,
} from './types'

export function validateContentType(c: Context, next: Next, options: HonoFileUploadDiskOption) {
const contentType = c.req.headers.get('Content-Type')

if (!contentType || options.skipNonFormBody) {
return next()
}

if (FORM_MIME_TYPES.every((type) => !contentType.startsWith(type))) {
throw new Error(`Content-Type must be ${FORM_MIME_TYPES.join(' or ')}`)
}
}

export async function getFiles(c: Context, options: HonoFileUploadDiskOption) {
const form = await c.req.parseBody()
const files = Object.entries(form).filter(([, value]) => value instanceof File) as unknown as [
string,
File
][]

if (files.length > options.totalFiles!) {
throw new Error('Too many files')
}

return files
}

export function normalizeOptions(
options: HonoFileUploadDiskOption,
defaultOptions: HonoFileUploadOption
): Required<HonoFileUploadDiskOption> {
if (typeof options.totalFiles === 'undefined') {
options.totalFiles = defaultOptions.totalFiles
}

if (typeof options.skipNonFormBody === 'undefined' || options.skipNonFormBody === null) {
options.skipNonFormBody = defaultOptions.skipNonFormBody
}

if (typeof options.prefixByMilliseconds === 'undefined') {
options.prefixByMilliseconds = defaultOptions.prefixByMilliseconds
}

if (typeof options.fileNameHandler === 'undefined') {
options.fileNameHandler = defaultOptions.fileNameHandler
}

if (typeof options.autoSetContext === 'undefined') {
options.autoSetContext = defaultOptions.autoSetContext
}

if (
typeof options.destination === 'undefined' ||
options.destination === null ||
options.destination === ''
) {
throw new Error('destination is required')
}

return options as Required<HonoFileUploadDiskOption>
}

export async function getFileInformation(value: File, options: HonoFileUploadDiskOption) {
const fileNames: string[] = []

if (options.prefixByMilliseconds) {
fileNames.push(Date.now().toString())
}

if (options.fileNameHandler) {
fileNames.push(options.fileNameHandler(value.name))
} else {
fileNames.push(value.name)
}

const finalName = fileNames.join('-')
const destination = path.resolve(options.destination, finalName)
const fileBuffer = Buffer.from(await value.arrayBuffer())

return {
fileBuffer,
destination,
finalName,
}
}

export async function uploadToFirestorage(value: File, options: HonoFileUploadFirebaseOption) {
const { destination, fileBuffer, finalName } = await getFileInformation(value, options)
const storage = options.storage.bucket().file(destination)

await storage.save(fileBuffer, {
contentType: value.type,
public: true,
})
const publicUri = storage.publicUrl()

return {
publicUri,
finalName,
destination,
}
}

export async function uploadToS3(value: File, options: HonoFileUploadAwsOption) {
const { destination, fileBuffer, finalName } = await getFileInformation(value, options)

const file = await options.storage
.upload({
Bucket: destination,
Key: finalName,
Body: fileBuffer,
})
.promise()

return {
publicUri: file.Location,
finalName,
destination,
}
}

export function autoSetToContext(
c: Context,
files: [string, File][],
options: HonoFileUploadOption
) {
if (!options.autoSetContext) return

// Set the uploaded files to the context
c.set('__files__', files)
}
136 changes: 136 additions & 0 deletions packages/file-upload/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import fs from 'fs-extra'
import type { MiddlewareHandler } from 'hono'
import { DEFAULT_MAX_SIZE, DEFAULT_MIN_SIZE } from './constants'
import {
autoSetToContext,
getFileInformation,
getFiles,
normalizeOptions,
uploadToFirestorage,
uploadToS3,
validateContentType,
} from './helper'
import type {
HonoFileUploadAwsOption,
HonoFileUploadDiskOption,
HonoFileUploadFirebaseOption,
HonoFileUploadOption,
} from './types'

export class HonoFileUpload {
private options: Required<HonoFileUploadOption>

constructor(options: HonoFileUploadOption = {}) {
this.options = {
fieldNameSize: options?.fieldNameSize ?? 100,
maxFileSize: options?.maxFileSize ?? DEFAULT_MAX_SIZE,
minFileSize: options?.minFileSize ?? DEFAULT_MIN_SIZE,
totalFiles: options?.totalFiles ?? Infinity,
skipNonFormBody: options?.skipNonFormBody ?? false,
prefixByMilliseconds: options?.prefixByMilliseconds ?? false,
fileNameHandler: options?.fileNameHandler ?? ((filename: string) => filename),
autoSetContext: options?.autoSetContext ?? false,
}
}

public saveToDisk(options: HonoFileUploadDiskOption): MiddlewareHandler {
options = normalizeOptions(options, this.options) as HonoFileUploadDiskOption

return async function (c, next) {
await validateContentType(c, next, options)

const files = await getFiles(c, options)

if (files.length === 0) {
return next()
}

if (!(await fs.exists(options.destination))) {
await fs.mkdirp(options.destination)
}

await Promise.all(
files.map(async ([, value]) => {
const { destination, fileBuffer, finalName } = await getFileInformation(value, options)

// Assign new attributes to the files object
Object.assign(value, {
savedTo: destination,
savedName: finalName,
originalName: value.name,
})

return fs.writeFile(destination, fileBuffer)
})
)

autoSetToContext(c, files, options)

return next()
}
}

public saveToFirebase(options: HonoFileUploadFirebaseOption): MiddlewareHandler {
options = normalizeOptions(options, this.options) as HonoFileUploadFirebaseOption

return async function (c, next) {
await validateContentType(c, next, options)

const files = await getFiles(c, options)

if (files.length === 0) {
return next()
}

await Promise.all(
files.map(async ([, value]) => {
const { destination, finalName, publicUri } = await uploadToFirestorage(value, options)

// Assign new attributes to the files object
Object.assign(value, {
savedTo: destination,
savedName: finalName,
originalName: value.name,
uri: publicUri,
})
})
)

autoSetToContext(c, files, options)

return next()
}
}

public saveToAws(options: HonoFileUploadAwsOption): MiddlewareHandler {
options = normalizeOptions(options, this.options) as HonoFileUploadAwsOption

return async function (c, next) {
await validateContentType(c, next, options)

const files = await getFiles(c, options)

if (files.length === 0) {
return next()
}

await Promise.all(
files.map(async ([, value]) => {
const { destination, finalName, publicUri } = await uploadToS3(value, options)

// Assign new attributes to the files object
Object.assign(value, {
savedTo: destination,
savedName: finalName,
originalName: value.name,
uri: publicUri,
})
})
)

autoSetToContext(c, files, options)

return next()
}
}
}
Loading

0 comments on commit 7ef0fa1

Please sign in to comment.