-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #21 from NCSU-App-Development-Club/image-multer
image routes types, multer file filter and size limit
- Loading branch information
Showing
6 changed files
with
167 additions
and
77 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.' }) | ||
} | ||
} | ||
}) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters