Skip to content

Commit

Permalink
feat: better content filtering (#569)
Browse files Browse the repository at this point in the history
* feat: better content filtering

* feat: keep multers3 for non asset/items

* chore: lock

* chore: logs
  • Loading branch information
nicosantangelo committed Jul 19, 2022
1 parent 6e89266 commit 8ed90d0
Show file tree
Hide file tree
Showing 8 changed files with 1,454 additions and 162 deletions.
1,441 changes: 1,322 additions & 119 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
"@types/mime-types": "^2.1.0",
"@types/morgan": "^1.9.2",
"@types/multer": "^1.4.5",
"@types/multer-s3": "^2.7.9",
"@types/pg": "^7.14.9",
"@types/uuid": "^8.3.1",
"@well-known-components/http-server": "^1.0.0",
Expand Down Expand Up @@ -71,10 +70,17 @@
"prom-client": "^13.1.0",
"tslint": "^6.1.3",
"typescript": "^4.1.5",
"interface-datastore": "^0.7.0",
"ipfs-unixfs-engine": "^0.35.4",
"pull-stream": "^3.6.12",
"cids": "^0.7.2",
"uuid": "^8.3.2",
"web3x": "^4.0.6"
},
"devDependencies": {
"@types/multer-s3": "^2.7.9",
"@types/interface-datastore": "^1.0.0",
"@types/pull-stream": "^3.6.2",
"@swc/core": "^1.2.102",
"@swc/jest": "^0.2.5",
"@types/isomorphic-fetch": "0.0.35",
Expand Down
42 changes: 20 additions & 22 deletions src/Asset/Asset.router.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
import { Request, Response } from 'express'
import multer from 'multer'
import { Request } from 'express'
import { server } from 'decentraland-server'
import { utils } from 'decentraland-commons'

import { Router } from '../common/Router'
import { HTTPError } from '../common/HTTPError'
import { getCID } from '../utils/cid'
import {
withModelExists,
asMiddleware,
withModelAuthorization,
} from '../middleware'
import { S3AssetPack, S3Content, getFileUploader, ACL } from '../S3'
import { S3Content, S3AssetPack, uploadRequestFiles } from '../S3'
import { AssetPack } from '../AssetPack'
import { Asset } from './Asset.model'
import { withAuthentication } from '../middleware/authentication'

export class AssetRouter extends Router {
assetFilesRequestHandler:
| ((req: Request, res: Response) => Promise<boolean>) // Promisified RequestHandler
| undefined

mount() {
const withAssetExists = withModelExists(Asset)
const withAssetPackExists = withModelExists(AssetPack, 'assetPackId')
Expand All @@ -27,8 +23,6 @@ export class AssetRouter extends Router {
'assetPackId'
)

this.assetFilesRequestHandler = this.getAssetFilesRequestHandler()

/**
* Upload the files for each asset in an asset pack
*/
Expand All @@ -38,6 +32,7 @@ export class AssetRouter extends Router {
withAssetPackExists,
withAssetPackAuthorization,
asMiddleware(this.assetBelongsToPackMiddleware),
multer().any(),
server.handleRequest(this.uploadAssetFiles)
)

Expand Down Expand Up @@ -68,13 +63,24 @@ export class AssetRouter extends Router {
}
}

uploadAssetFiles = async (req: Request, res: Response) => {
uploadAssetFiles = async (req: Request) => {
try {
await this.assetFilesRequestHandler!(req, res)
await uploadRequestFiles(req.files, async (file) => {
const hash = await getCID({
path: file.originalname,
content: file.buffer,
size: file.size,
})
if (hash !== file.fieldname) {
throw new Error(
'The CID supplied does not correspond to the actual hash of the file'
)
}
return new S3Content().getFileKey(hash)
})
} catch (error) {
const assetPackId = server.extractFromReq(req, 'assetPackId')
const s3AssetPack = new S3AssetPack(assetPackId)

try {
await Promise.all([
AssetPack.hardDelete({ id: assetPackId }),
Expand All @@ -83,20 +89,12 @@ export class AssetRouter extends Router {
} catch (error) {
// Skip
}

throw new HTTPError('An error occurred trying to upload asset files', {
message: error.message,
message: (error as Error).message,
})
}
}

private getAssetFilesRequestHandler() {
const uploader = getFileUploader({ acl: ACL.publicRead }, (_, file) =>
new S3Content().getFileKey(file.fieldname)
)
return utils.promisify<boolean>(uploader.any())
}

private getAsset(req: Request) {
const id = server.extractFromReq(req, 'id')
return Asset.findOne(id)
Expand Down
35 changes: 16 additions & 19 deletions src/Item/Item.router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Request, Response } from 'express'
import { utils } from 'decentraland-commons'
import multer from 'multer'
import { Request } from 'express'
import { hashV1 } from '@dcl/hashing'
import { server } from 'decentraland-server'
import { omit } from 'decentraland-commons/dist/utils'
import { Router } from '../common/Router'
Expand All @@ -15,7 +16,7 @@ import {
withSchemaValidation,
} from '../middleware'
import { OwnableModel } from '../Ownable'
import { S3Item, getFileUploader, ACL, S3Content } from '../S3'
import { S3Content, S3Item, uploadRequestFiles } from '../S3'
import { Collection, CollectionService } from '../Collection'
import { hasPublicAccess as hasCollectionAccess } from '../Collection/access'
import { NonExistentCollectionError } from '../Collection/Collection.errors'
Expand Down Expand Up @@ -54,10 +55,6 @@ export class ItemRouter extends Router {
public collectionService = new CollectionService()
private itemService = new ItemService()

itemFilesRequestHandler:
| ((req: Request, res: Response) => Promise<boolean>) // Promisified RequestHandler
| undefined

private modelAuthorizationCheck = (
_: OwnableModel,
id: string,
Expand All @@ -76,8 +73,6 @@ export class ItemRouter extends Router {
)
const withLowercasedAddress = withLowercasedParams(['address'])

this.itemFilesRequestHandler = this.getItemFilesRequestHandler()

/**
* Returns all items
*/
Expand Down Expand Up @@ -147,6 +142,7 @@ export class ItemRouter extends Router {
withAuthentication,
withItemExists,
withItemAuthorization,
multer().any(),
server.handleRequest(this.uploadItemFiles)
)
}
Expand Down Expand Up @@ -477,11 +473,19 @@ export class ItemRouter extends Router {
return true
}

uploadItemFiles = async (req: Request, res: Response) => {
const id = server.extractFromReq(req, 'id')
uploadItemFiles = async (req: Request) => {
try {
await this.itemFilesRequestHandler!(req, res)
await uploadRequestFiles(req.files, async (file) => {
const hash = await hashV1(file.buffer)
if (hash !== file.fieldname) {
throw new Error(
'The CID supplied does not correspond to the actual hash of the file'
)
}
return new S3Content().getFileKey(hash)
})
} catch (error: any) {
const id = server.extractFromReq(req, 'id')
try {
await Promise.all([Item.delete({ id }), new S3Item(id).delete()])
} catch (error) {
Expand All @@ -493,11 +497,4 @@ export class ItemRouter extends Router {
})
}
}

private getItemFilesRequestHandler() {
const uploader = getFileUploader({ acl: ACL.publicRead }, (_, file) => {
return new S3Content().getFileKey(file.fieldname)
})
return utils.promisify<boolean>(uploader.any())
}
}
1 change: 1 addition & 0 deletions src/S3/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './s3'
export * from './manifest'
export * from './pool'
export * from './uploadRequestFiles'
export * from './S3Project'
export * from './S3AssetPack'
export * from './S3Content'
Expand Down
6 changes: 5 additions & 1 deletion src/S3/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ if (!ACCESS_KEY || !ACCESS_SECRET) {
)
}

const BUCKET_NAME = env.get('AWS_BUCKET_NAME', '')
export const BUCKET_NAME = env.get('AWS_BUCKET_NAME', '')
if (!BUCKET_NAME) {
throw new Error(
'You need to add an AWS bucket name to your env file. Check the .env.example file'
Expand Down Expand Up @@ -185,6 +185,10 @@ export function getFileUploader(
})
}

export function isValidFileSize(size: number) {
return size <= MAX_FILE_SIZE
}

export const getBucketURL = (): string =>
STORAGE_URL
? `${STORAGE_URL}/${BUCKET_NAME}`
Expand Down
48 changes: 48 additions & 0 deletions src/S3/uploadRequestFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ACL, ACLValues, isValidFileSize, uploadFile } from './s3'

type File = Express.Multer.File

export async function uploadRequestFiles(
files: { [fieldname: string]: File[] } | File[],
getFileKey: (file: File) => Promise<string>,
options: Partial<{ acl: ACLValues; mimeTypes: string[] }> = {}
): Promise<File[]> {
// We don't care about the response type here, we're just awaiting on the promises
const uploadPromises: Promise<Object>[] = []
const acl = options.acl || ACL.publicRead

// Transform the files into an array of files
// req.files can be either an object with: { [fieldName]: Express.MulterS3.File[] } or an array of Express.MulterS3.File[]
// depending on which method you use, multer().array() or multer().fields().
// The field name is still accessible on each File
// If you're using fields() with a maxCount bigger than 1, you might want to use the original req.files object to do any further processing
files = Array.isArray(files) ? files : Object.values(files).flat()

for (const file of files) {
if (
!isValidFileSize(file.size) ||
!isValidMimeType(file.mimetype, options.mimeTypes)
) {
throw new Error(
`Invalid file ${file.fieldname}. Check the file size and mimetype.`
)
}

uploadPromises.push(
getFileKey(file).then((hash) => uploadFile(hash, file.buffer, acl))
)
}

await Promise.all(uploadPromises)

return files
}

function isValidMimeType(
mimeTypeToCheck: string,
validMimeTypes: string[] = [mimeTypeToCheck]
) {
return validMimeTypes
.map((mimeType) => mimeType.toLowerCase())
.includes(mimeTypeToCheck.toLowerCase())
}
35 changes: 35 additions & 0 deletions src/utils/cid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import pull from 'pull-stream'
import { MemoryDatastore } from 'interface-datastore'
import CID from 'cids'
const Importer = require('ipfs-unixfs-engine').Importer

export type ContentServiceFile = {
path: string
content: Buffer
size: number
}

export async function getCID(file: ContentServiceFile): Promise<string> {
const importer = new Importer(new MemoryDatastore(), { onlyHash: true })
return new Promise<string>((resolve, reject) => {
pull(
pull.values([file]),
pull.asyncMap((file: ContentServiceFile, cb: any) => {
const data = {
path: file.path,
content: file.content,
}
cb(null, data)
}),
importer,
pull.onEnd(() => {
return importer.flush((err: any, content: any) => {
if (err) {
reject(err)
}
resolve(new CID(content).toBaseEncodedString())
})
})
)
})
}

0 comments on commit 8ed90d0

Please sign in to comment.