Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ 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.

## CI Baseline

Starter workflows are included for backend/mobile PR checks:
Expand Down
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,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=
17 changes: 17 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,20 @@ npm run dev

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`
- 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)
3 changes: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,22 @@
"seed": "tsx scripts/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.883.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.1",
"helmet": "^7.1.0",
"mongoose": "^8.6.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/node": "^20.16.5",
"@typescript-eslint/eslint-plugin": "^8.8.1",
"@typescript-eslint/parser": "^8.8.1",
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
9 changes: 9 additions & 0 deletions backend/src/middleware/error.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { NextFunction, Request, Response } from "express"
import multer from "multer"

export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction): void {
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
}

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,6 +1,8 @@
import { Router } from "express"
import { healthRouter } from "./health.routes.js"
import { uploadRouter } from "./upload.routes.js"

export const apiRouter = Router()

apiRouter.use("/health", healthRouter)
apiRouter.use("/upload", uploadRouter)
31 changes: 31 additions & 0 deletions backend/src/routes/upload.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Router } from "express"
import multer from "multer"
import { uploadImageToS3 } from "../services/upload.service.js"

const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 },
})

Comment on lines +8 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add strict MIME allowlist enforcement in the upload route (jpeg/png/webp check), focus on

  • MIME type allowlist enforcement (image/jpeg, image/png, image/webp)
  • File size validation behavior and response shape
  • S3 key/public URL safety and error handling
  • Env var assumptions and missing-config behavior

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)
}
})
47 changes: 47 additions & 0 deletions backend/src/services/upload.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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,
}
}