Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,13 @@ cp .env.example .env
npm run start
```

## Backend Upload Service

The backend exposes:

- `POST /api/upload` (multipart form-data)
- file field name: `file`
- max file size: `5MB`
- storage target: S3

See `/backend/.env.example` for required AWS variables.
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ JWT_SECRET=replace_me
STELLAR_NETWORK=testnet
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
STELLAR_DESTINATION_ADDRESS=
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_BUCKET=
AWS_S3_PUBLIC_BASE_URL=
23 changes: 18 additions & 5 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ npm run dev
## Seed Data

Run the seed script to create:
Run:

```bash
npm run seed
Expand All @@ -36,10 +35,6 @@ This creates:
- 1 dummy restaurant
- 5 dummy food items

```bash
npm run seed
```

## Validation Example

`POST /api/ping` validates request bodies with Zod.
Expand All @@ -60,6 +55,24 @@ Invalid payloads return a structured `400` response with a `details` array.
This folder is the target for Phase 1 and Phase 2 backend issues.
Legacy NestJS code is archived under `/legacy/nest-backend`.

## Upload Endpoint

`POST /api/upload`

- Content-Type: `multipart/form-data`
- Field name: `file`
- Max file size: `5MB`
- Allowed image types: `jpeg`, `png`, `webp`
- Response: `{ ok: true, url: "https://..." }`

Required environment variables:

- `AWS_REGION`
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `AWS_S3_BUCKET`
- `AWS_S3_PUBLIC_BASE_URL` (optional)

## Docker

From repository root:
Expand Down
3 changes: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"seed": "tsx scripts/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.883.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
Expand All @@ -24,6 +25,7 @@
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.6.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"zod": "^3.23.8"
},
"devDependencies": {
Expand All @@ -32,6 +34,7 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/node": "^20.16.5",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.18.0",
Expand Down
5 changes: 5 additions & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ const envSchema = z.object({
STELLAR_NETWORK: z.enum(["testnet", "public"]).default("testnet"),
STELLAR_HORIZON_URL: z.string().url().optional(),
STELLAR_DESTINATION_ADDRESS: z.string().optional(),
AWS_REGION: z.string().optional(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
AWS_S3_BUCKET: z.string().optional(),
AWS_S3_PUBLIC_BASE_URL: z.string().url().optional(),
})

const parsed = envSchema.safeParse(process.env)
Expand Down
16 changes: 16 additions & 0 deletions backend/src/middleware/error.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import type { NextFunction, Request, Response } from "express"
import multer from "multer"

export function errorHandler(err: unknown, _req: Request, res: Response, next: NextFunction): void {
void next
if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") {
res.status(400).json({
error: "Bad Request",
message: "File exceeds maximum size of 5MB",
})
return
}

if (err instanceof Error && err.message.startsWith("Unsupported file type.")) {
res.status(400).json({
error: "Bad Request",
message: err.message,
})
return
}
const message = err instanceof Error ? err.message : "Internal Server Error"
res.status(500).json({
error: "Internal Server Error",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Router } from "express"
import { authRouter } from "./auth.routes.js"
import { healthRouter } from "./health.routes.js"
import { uploadRouter } from "./upload.routes.js"
import { swipeRouter } from "./swipe.routes.js"
import { restaurantFoodsRouter } from "./restaurant-foods.routes.js"
import { pingRouter } from "./ping.routes.js"

export const apiRouter = Router()

apiRouter.use("/health", healthRouter)
apiRouter.use("/upload", uploadRouter)
apiRouter.use("/swipe", swipeRouter)
apiRouter.use("/restaurant/foods", restaurantFoodsRouter)
apiRouter.use("/auth", authRouter)
Expand Down
41 changes: 41 additions & 0 deletions backend/src/routes/upload.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Router } from "express"
import multer from "multer"
import { uploadImageToS3 } from "../services/upload.service.js"

const ALLOWED_IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/webp"])
const INVALID_UPLOAD_TYPE_MESSAGE = "Unsupported file type. Allowed types: jpeg, png, webp."

const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, callback) => {
if (ALLOWED_IMAGE_MIME_TYPES.has(file.mimetype)) {
callback(null, true)
return
}
callback(new Error(INVALID_UPLOAD_TYPE_MESSAGE))
},
})

export const uploadRouter = Router()

uploadRouter.post("/", upload.single("file"), async (req, res, next) => {
try {
if (!req.file) {
res.status(400).json({
error: "Bad Request",
message: "Missing image file in form-data field 'file'",
})
return
}

const { url } = await uploadImageToS3(req.file)

res.status(201).json({
ok: true,
url,
})
} catch (error) {
next(error)
}
})
56 changes: 56 additions & 0 deletions backend/src/services/upload.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { randomUUID } from "node:crypto"
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
import { env } from "../config/env.js"

function ensureS3Config() {
if (
!env.AWS_REGION ||
!env.AWS_ACCESS_KEY_ID ||
!env.AWS_SECRET_ACCESS_KEY ||
!env.AWS_S3_BUCKET
) {
throw new Error(
"S3 config missing. Set AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET.",
)
}
}

function getS3Client() {
ensureS3Config()
return new S3Client({
region: env.AWS_REGION,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY_ID!,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY!,
},
})
}

function getPublicUrl(key: string): string {
if (env.AWS_S3_PUBLIC_BASE_URL) {
return `${env.AWS_S3_PUBLIC_BASE_URL.replace(/\/$/, "")}/${key}`
}
return `https://${env.AWS_S3_BUCKET}.s3.${env.AWS_REGION}.amazonaws.com/${key}`
}

export async function uploadImageToS3(
file: Express.Multer.File,
): Promise<{ url: string; key: string }> {
const extension = file.originalname.includes(".") ? file.originalname.split(".").pop() : "bin"
const key = `uploads/${new Date().toISOString().slice(0, 10)}/${randomUUID()}.${extension}`

const client = getS3Client()
const command = new PutObjectCommand({
Bucket: env.AWS_S3_BUCKET,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
})

await client.send(command)

return {
url: getPublicUrl(key),
key,
}
}
41 changes: 41 additions & 0 deletions backend/test/upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import test from "node:test"
import request from "supertest"
import { createApp } from "../src/app.js"

test("POST /api/upload returns 400 when file is missing", async () => {
const app = createApp()

const response = await request(app).post("/api/upload")

assert.equal(response.status, 400)
assert.equal(response.body.error, "Bad Request")
assert.equal(response.body.message, "Missing image file in form-data field 'file'")
})

test("POST /api/upload returns 400 for unsupported file type", async () => {
const app = createApp()

const response = await request(app).post("/api/upload").attach("file", Buffer.from("hello"), {
filename: "note.txt",
contentType: "text/plain",
})

assert.equal(response.status, 400)
assert.equal(response.body.error, "Bad Request")
assert.equal(response.body.message, "Unsupported file type. Allowed types: jpeg, png, webp.")
})

test("POST /api/upload returns 400 when file exceeds 5MB", async () => {
const app = createApp()
const bigBuffer = Buffer.alloc(5 * 1024 * 1024 + 1, 1)

const response = await request(app).post("/api/upload").attach("file", bigBuffer, {
filename: "too-large.png",
contentType: "image/png",
})

assert.equal(response.status, 400)
assert.equal(response.body.error, "Bad Request")
assert.equal(response.body.message, "File exceeds maximum size of 5MB")
})
Loading