Skip to content

Commit

Permalink
Merge pull request #21 from NCSU-App-Development-Club/image-multer
Browse files Browse the repository at this point in the history
image routes types, multer file filter and size limit
  • Loading branch information
Brendon-Hablutzel authored Mar 2, 2025
2 parents 038f893 + ca8f036 commit b61e119
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 77 deletions.
6 changes: 4 additions & 2 deletions src/dto/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { z } from 'zod'

export const ImageSubmissionFormSchema = z.object({
export const ImageSubmissionForm = z.object({
latitude: z.string().transform(Number),
longitude: z.string().transform(Number),
description: z.string(),
takenAt: z.string().transform((val) => new Date(val)),
locationName: z.string(),
})

export type ImageSubmissionForm = z.infer<typeof ImageSubmissionFormSchema>
export type ImageSubmissionFormType = z.infer<typeof ImageSubmissionForm>

export const ErrorResponseBody = z.object({
error: z.string().optional(),
})

export type ErrorResponseBodyType = z.infer<typeof ErrorResponseBody>
2 changes: 1 addition & 1 deletion src/auth.ts → src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import express from 'express'
import { getAdminSecretToken } from './util'
import { getAdminSecretToken } from '../util'

export const adminAuthMiddleware = async (
req: express.Request,
Expand Down
13 changes: 13 additions & 0 deletions src/middleware/transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import express from 'express'
import { toCamelCaseBody } from '../util/routes'

// Express middleware for transforming kebab-case request bodies to camelCase.
// TODO: look into using lodash for converting all bodies of any form into camelCase.
export const camelCaseBodyMiddleware = async (
req: express.Request,
res: express.Response,
next: express.NextFunction
): Promise<void> => {
req.body = toCamelCaseBody(req.body)
next()
}
2 changes: 1 addition & 1 deletion src/routes/game.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import express from 'express'
import { adminAuthMiddleware } from '../auth'
import { adminAuthMiddleware } from '../middleware/auth'
import { z } from 'zod'
import { getGameByDate, getGames, insertGame } from '../repository/game'
import { getImage } from '../repository/image'
Expand Down
211 changes: 138 additions & 73 deletions src/routes/image.ts
Original file line number Diff line number Diff line change
@@ -1,99 +1,164 @@
import express from 'express'
import multer from 'multer'
import express, { Request, Response } from 'express'
import multer, { FileFilterCallback } from 'multer'
import {
getImage,
getUnvalidatedImages,
insertImage,
} from '../repository/image'
import { NewImageType } from '../models/image'
import { ImageRowType, NewImageType, ImageRowsType } from '../models/image'
import { toCamelCaseBody } from '../util/routes'
import { generateGetSignedUrl, putToS3 } from '../util/s3'
import { deleteFromS3, generateGetSignedUrl, putToS3 } from '../util/s3'
import { randomUUID } from 'node:crypto'
import { adminAuthMiddleware } from '../auth'
import { ImageSubmissionFormSchema } from '../dto'
import { adminAuthMiddleware } from '../middleware/auth'
import {
ErrorResponseBodyType,
ImageSubmissionForm,
ImageSubmissionFormType,
} from '../dto'
import { logger } from '../util'
import { camelCaseBodyMiddleware } from '../middleware/transform'

const fileFilter = (
req: Request,
file: Express.Multer.File,
cb: FileFilterCallback
) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true)
} else {
cb(null, false)
}
}

const storage = multer.memoryStorage()
const upload = multer({ storage })
const limits = {
fileSize: 1024 * 1024 * 5, // 5MB limit
files: 1,
}
const upload = multer({ fileFilter, storage, limits })

export const imageRouter = express.Router()

// takes an image in the body, uploads it to s3, and adds it to the database--ensure that it acts like a transaction
imageRouter.post('/', upload.single('image'), async (req, res) => {
const pre: any = toCamelCaseBody(req.body)
const parsedImageBody = ImageSubmissionFormSchema.safeParse(pre)
if (parsedImageBody.error || !req.file) {
return (
res.status(400).send({
error: `invalid body: ${parsedImageBody.error ? parsedImageBody.error.message : 'missing image'}`,
}),
undefined
)
}
// Takes an image in the body, uploads it to s3, and adds it to the database.
// If s3 put fails, nothing is written to db. If s3 put succeeds and db write
// fails, attempts delete object from s3.
imageRouter.post(
'/',
upload.single('image'),
camelCaseBodyMiddleware,
async (
req: Request<{}, {}, ImageSubmissionFormType>,
res: Response<ImageRowType | ErrorResponseBodyType>
) => {
if (!req.file) {
return (
res
.status(400)
.send({ error: 'Invalid body: missing or invalid image.' }),
undefined
)
}
const parsedImageBody = ImageSubmissionForm.safeParse(req.body)
if (parsedImageBody.error) {
return (
res.status(400).send({
error: JSON.parse(parsedImageBody.error.message),
}),
undefined
)
}

const fileLocation = randomUUID().trim()
const fileLocation = randomUUID().trim()

// Add to S3
try {
await putToS3(fileLocation, req.file.buffer)
} catch (e) {
logger.error(`error uploading to s3: ${e}`)
return res.status(500).send('error uploading image to S3'), undefined
}
// Add to S3
try {
await putToS3(fileLocation, req.file.buffer)
} catch (e) {
logger.error(`error uploading to s3: ${e}`)
return (
res.status(500).send({ error: 'Error uploading image to S3.' }),
undefined
)
}

// Add to SQL
const newImage: NewImageType = {
...parsedImageBody.data,
fileLocation,
validated: false,
}
// Add to SQL
const newImage: NewImageType = {
...parsedImageBody.data,
fileLocation,
validated: false,
}

console.log(newImage)
try {
const result = await insertImage(newImage)
return res.status(200).send(result), undefined
} catch (e) {
logger.error(`error inserting image into db: ${e}`)
}

try {
const result = await insertImage(newImage)
res.status(200).send(result)
} catch (e) {
logger.error(`error inserting image into db: ${e}`)
res.status(500).send({ error: "couldn't write to db" })
// rollback s3 insertion if db write fails
try {
await deleteFromS3(fileLocation)
} catch (e) {
logger.warn(
`orphaned element in S3 with key: ${fileLocation}. s3 error: ${e}`
)
}
res.status(500).send({ error: "Couldn't write to DB." })
}
})
)

// gets all non-validated images that have not been used for games (just data, not actual images)
// (admin)
imageRouter.get('/', adminAuthMiddleware, async (req, res) => {
try {
const unvalidatedImages = await getUnvalidatedImages()
res.status(200).send({ images: unvalidatedImages })
} catch (e) {
logger.error(`error fetching unvalidated images: ${e}`)
res.status(500).send({ error: 'failed to fetch unvalidated images' })
// Gets all non-validated images that have not been used for games
// (just data, not actual images). Admin-only.
imageRouter.get(
'/',
adminAuthMiddleware,
async (
req: Request<{}, {}, {}>,
res: Response<{ images: ImageRowsType } | ErrorResponseBodyType>
) => {
try {
const unvalidatedImages = await getUnvalidatedImages()
res.status(200).send({ images: unvalidatedImages })
} catch (e) {
logger.error(`error fetching unvalidated images: ${e}`)
res.status(500).send({ error: 'Failed to fetch unvalidated images.' })
}
}
})
)

// route to generate a presigned url for getting a specific image from s3 by id
imageRouter.get('/:imageId/url', async (req, res) => {
const imageId = parseInt(req.params.imageId)
if (isNaN(imageId) || imageId.toString() !== req.params.imageId)
return res.status(400).send('bad image id'), undefined
// Route to generate a presigned URL for getting a specific image from s3 by ID.
imageRouter.get(
'/:imageId/url',
async (
req: Request<{ imageId: string }, {}, {}>,
res: Response<{ signedUrl: string } | ErrorResponseBodyType>
) => {
const imageId = parseInt(req.params.imageId)
if (isNaN(imageId) || imageId.toString() !== req.params.imageId)
return res.status(400).send({ error: 'Invalid image ID.' }), undefined

let imageKey
try {
imageKey = await getImage(imageId)
} catch (e) {
logger.error(`failed to fetch image from db: ${e}`)
return res.status(500).send({ error: "couldn't get image key" }), undefined
}
let imageKey
try {
imageKey = await getImage(imageId)
} catch (e) {
logger.error(`failed to fetch image from db: ${e}`)
return (
res.status(500).send({ error: "Couldn't get image key." }), undefined
)
}

if (!imageKey)
return res.status(500).send({ error: "couldn't get image key" }), undefined
if (!imageKey)
return (
res.status(500).send({ error: "Couldn't get image key." }), undefined
)

console.log(imageKey)
try {
const signedUrl = await generateGetSignedUrl(imageKey.fileLocation, 60000)
res.status(200).send({ signedUrl })
} catch (e) {
logger.error(`failed to get presigned url: ${e}`)
res.status(500).send({ error: 'error fetching URL' })
console.log(imageKey)
try {
const signedUrl = await generateGetSignedUrl(imageKey.fileLocation, 60000)
res.status(200).send({ signedUrl })
} catch (e) {
logger.error(`failed to get presigned url: ${e}`)
res.status(500).send({ error: 'Error fetching URL.' })
}
}
})
)
10 changes: 10 additions & 0 deletions src/util/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
GetObjectCommand,
CreateBucketCommand,
PutObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { DeployEnv, getDeployEnv } from '.'
Expand Down Expand Up @@ -56,3 +57,12 @@ export const putToS3 = async (

await s3.send(command)
}

export const deleteFromS3 = async (key: string) => {
const command = new DeleteObjectCommand({
Bucket: s3BucketName,
Key: key,
})

await s3.send(command)
}

0 comments on commit b61e119

Please sign in to comment.