diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index d8f736b..960ae55 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -19,6 +19,7 @@ const main = async () => { "/auth/me": withCors(withAuth(routes.authMe)), "/user/update": withCors(withAuth(routes.userUpdate)), + "/user/upload-avatar": withCors(routes.userUploadAvatar), "/issue/create": withCors(withAuth(routes.issueCreate)), "/issue/update": withCors(withAuth(routes.issueUpdate)), diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 4daf7df..d385809 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -24,6 +24,7 @@ import projectUpdate from "./project/update"; import projectWithCreator from "./project/with-creator"; import projectsWithCreators from "./project/with-creators"; import userUpdate from "./user/update"; +import userUploadAvatar from "./user/upload-avatar"; export const routes = { authRegister, @@ -31,6 +32,7 @@ export const routes = { authMe, userUpdate, + userUploadAvatar, issueCreate, issueDelete, diff --git a/packages/backend/src/routes/user/upload-avatar.ts b/packages/backend/src/routes/user/upload-avatar.ts new file mode 100644 index 0000000..f8edebc --- /dev/null +++ b/packages/backend/src/routes/user/upload-avatar.ts @@ -0,0 +1,60 @@ +import { randomUUID } from "node:crypto"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import type { BunRequest } from "bun"; +import { bucketName, s3Client, s3Endpoint, s3PublicUrl } from "../../s3"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; +const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; +const ALLOWED_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"]; + +export default async function uploadAvatar(req: BunRequest) { + if (req.method !== "POST") { + return new Response("method not allowed", { status: 405 }); + } + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + + if (!file) { + return new Response("file is required", { status: 400 }); + } + + if (file.size > MAX_FILE_SIZE) { + return new Response("file size exceeds 5MB limit", { status: 400 }); + } + + if (!ALLOWED_TYPES.includes(file.type)) { + return new Response("invalid file type. Allowed types: png, jpg, jpeg, webp, gif", { + status: 400, + }); + } + + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + if (!fileExtension || !ALLOWED_EXTENSIONS.includes(fileExtension)) { + return new Response("invalid file extension", { status: 400 }); + } + + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + + const uuid = randomUUID(); + const key = `avatars/${uuid}.${fileExtension}`; + const publicUrlBase = s3PublicUrl || s3Endpoint; + const publicUrl = `${publicUrlBase}/${key}`; + + try { + await s3Client.send( + new PutObjectCommand({ + Bucket: bucketName, + Key: key, + Body: buffer, + ContentType: file.type, + }), + ); + } catch (error) { + console.error("failed to upload to S3:", error); + return new Response("failed to upload image", { status: 500 }); + } + + return Response.json({ avatarURL: publicUrl }); +}