diff --git a/src/dto/index.ts b/src/dto/index.ts index 2645401..21a8ebb 100644 --- a/src/dto/index.ts +++ b/src/dto/index.ts @@ -1,6 +1,6 @@ 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(), @@ -8,8 +8,10 @@ export const ImageSubmissionFormSchema = z.object({ locationName: z.string(), }) -export type ImageSubmissionForm = z.infer +export type ImageSubmissionFormType = z.infer export const ErrorResponseBody = z.object({ error: z.string().optional(), }) + +export type ErrorResponseBodyType = z.infer diff --git a/src/auth.ts b/src/middleware/auth.ts similarity index 93% rename from src/auth.ts rename to src/middleware/auth.ts index b03ebab..657a5e0 100644 --- a/src/auth.ts +++ b/src/middleware/auth.ts @@ -1,5 +1,5 @@ import express from 'express' -import { getAdminSecretToken } from './util' +import { getAdminSecretToken } from '../util' export const adminAuthMiddleware = async ( req: express.Request, diff --git a/src/middleware/transform.ts b/src/middleware/transform.ts new file mode 100644 index 0000000..632173a --- /dev/null +++ b/src/middleware/transform.ts @@ -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 => { + req.body = toCamelCaseBody(req.body) + next() +} diff --git a/src/routes/game.ts b/src/routes/game.ts index ef12be2..a4b6da9 100644 --- a/src/routes/game.ts +++ b/src/routes/game.ts @@ -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' diff --git a/src/routes/image.ts b/src/routes/image.ts index 557d85c..8c837a5 100644 --- a/src/routes/image.ts +++ b/src/routes/image.ts @@ -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 + ) => { + 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.' }) + } } -}) +) diff --git a/src/util/s3.ts b/src/util/s3.ts index 00f2de5..67fa3f1 100644 --- a/src/util/s3.ts +++ b/src/util/s3.ts @@ -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 '.' @@ -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) +}