From 976373a65ba21d433ba9efc39aef13d7b54b32b0 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 4 Feb 2026 20:46:23 +0000 Subject: [PATCH] refactor: replace presigned URL uploads with backend upload endpoints --- apps/web/src/pages/api/upload/attachment.ts | 133 ++++++++++++++++++ apps/web/src/pages/api/upload/avatar.ts | 101 +++++++++++++ apps/web/src/pages/api/upload/image.ts | 75 ---------- .../card/components/AttachmentUpload.tsx | 72 +++------- 4 files changed, 256 insertions(+), 125 deletions(-) create mode 100644 apps/web/src/pages/api/upload/attachment.ts create mode 100644 apps/web/src/pages/api/upload/avatar.ts delete mode 100644 apps/web/src/pages/api/upload/image.ts diff --git a/apps/web/src/pages/api/upload/attachment.ts b/apps/web/src/pages/api/upload/attachment.ts new file mode 100644 index 00000000..d97cf4ac --- /dev/null +++ b/apps/web/src/pages/api/upload/attachment.ts @@ -0,0 +1,133 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; + +import { createNextApiContext } from "@kan/api/trpc"; +import * as cardRepo from "@kan/db/repository/card.repo"; +import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo"; +import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo"; +import { generateUID } from "@kan/shared/utils"; + +import { env } from "~/env"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; +import { createS3Client } from "@kan/api/utils/s3"; +import { assertPermission } from "@kan/api/utils/permissions"; + +const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB +const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"]; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + try { + const { user, db } = await createNextApiContext(req); + + if (!user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; + if (!bucket) { + return res.status(500).json({ error: "Attachments bucket not configured" }); + } + + const cardPublicId = req.query.cardPublicId; + if (typeof cardPublicId !== "string" || cardPublicId.length < 12) { + return res.status(400).json({ error: "Invalid cardPublicId" }); + } + + const contentType = req.headers["content-type"]; + const contentLengthHeader = req.headers["content-length"]; + const contentLength = contentLengthHeader + ? Number.parseInt(contentLengthHeader, 10) + : NaN; + + if (typeof contentType !== "string") { + return res.status(400).json({ error: "Missing content type" }); + } + + if (!allowedContentTypes.includes(contentType)) { + return res.status(400).json({ error: "Invalid content type" }); + } + + if (!Number.isFinite(contentLength) || contentLength <= 0) { + return res.status(400).json({ error: "Missing or invalid content length" }); + } + + if (contentLength > MAX_SIZE_BYTES) { + return res.status(400).json({ error: "File too large" }); + } + + const originalFilenameHeader = + (req.headers["x-original-filename"] as string | undefined) ?? "file"; + + const sanitizedFilename = originalFilenameHeader + .replace(/[^a-zA-Z0-9._-]/g, "_") + .substring(0, 200); + + // Get card and check permissions + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( + db, + cardPublicId, + ); + + if (!card) { + return res.status(404).json({ error: "Card not found" }); + } + + // Check if user has permission to edit the card + try { + await assertPermission(db, user.id, card.workspaceId, "card:edit"); + } catch { + return res.status(403).json({ error: "Permission denied" }); + } + + const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`; + + const client = createS3Client(); + + // Upload the file to S3 + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: s3Key, + Body: req, + ContentType: contentType, + ContentLength: contentLength, + }), + ); + + // Create attachment record and log activity + const attachment = await cardAttachmentRepo.create(db, { + cardId: card.id, + filename: sanitizedFilename, + originalFilename: originalFilenameHeader, + contentType, + size: contentLength, + s3Key, + createdBy: user.id, + }); + + await cardActivityRepo.create(db, { + type: "card.updated.attachment.added", + cardId: card.id, + createdBy: user.id, + }); + + return res.status(200).json({ attachment }); + } catch (error) { + console.error("Attachment upload failed", error); + return res.status(500).json({ error: "Internal server error" }); + } + }, +); + diff --git a/apps/web/src/pages/api/upload/avatar.ts b/apps/web/src/pages/api/upload/avatar.ts new file mode 100644 index 00000000..daff47e8 --- /dev/null +++ b/apps/web/src/pages/api/upload/avatar.ts @@ -0,0 +1,101 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; + +import { createNextApiContext } from "@kan/api/trpc"; +import * as userRepo from "@kan/db/repository/user.repo"; + +import { env } from "~/env"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; +import { createS3Client } from "@kan/api/utils/s3"; + +const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB +const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"]; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + try { + const { user, db } = await createNextApiContext(req); + + if (!user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME; + if (!bucket) { + return res.status(500).json({ error: "Avatar bucket not configured" }); + } + + const contentType = req.headers["content-type"]; + const contentLengthHeader = req.headers["content-length"]; + const contentLength = contentLengthHeader + ? Number.parseInt(contentLengthHeader, 10) + : NaN; + + if (typeof contentType !== "string") { + return res.status(400).json({ error: "Missing content type" }); + } + + if (!allowedContentTypes.includes(contentType)) { + return res.status(400).json({ error: "Invalid content type" }); + } + + if (!Number.isFinite(contentLength) || contentLength <= 0) { + return res.status(400).json({ error: "Missing or invalid content length" }); + } + + if (contentLength > MAX_SIZE_BYTES) { + return res.status(400).json({ error: "File too large" }); + } + + const originalFilenameHeader = + (req.headers["x-original-filename"] as string | undefined) ?? "file"; + + const sanitizedFilename = originalFilenameHeader + .replace(/[^a-zA-Z0-9._-]/g, "_") + .substring(0, 200); + + const s3Key = `${user.id}/${sanitizedFilename}`; + + const client = createS3Client(); + + // Upload the file to S3 + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: s3Key, + Body: req, + ContentType: contentType, + ContentLength: contentLength, + }), + ); + + // Update user image in database + const updatedUser = await userRepo.update(db, user.id, { + image: s3Key, + }); + + return res.status(200).json({ + key: s3Key, + filename: sanitizedFilename, + contentType, + size: contentLength, + user: updatedUser, + }); + } catch (error) { + console.error("Avatar upload failed", error); + return res.status(500).json({ error: "Internal server error" }); + } + }, +); + diff --git a/apps/web/src/pages/api/upload/image.ts b/apps/web/src/pages/api/upload/image.ts deleted file mode 100644 index 1ead915c..00000000 --- a/apps/web/src/pages/api/upload/image.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { env as nextRuntimeEnv } from "next-runtime-env"; - -import { createNextApiContext } from "@kan/api/trpc"; - -import { env } from "~/env"; -import { withRateLimit } from "@kan/api/utils/rateLimit"; - -const allowedContentTypes = ["image/jpeg", "image/png"]; - -export default withRateLimit( - { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") { - return res.status(405).json({ error: "Method not allowed" }); - } - - try { - const { user } = await createNextApiContext(req); - - if (!user) { - return res.status(401).json({ error: "Unauthorized" }); - } - - const { filename, contentType } = req.body as { - filename: string; - contentType: string; - }; - - // Specific to avatar uploads for now - const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/; - - if (!filenameRegex.test(filename)) { - return res.status(400).json({ error: "Invalid filename" }); - } - - if ( - typeof contentType !== "string" || - !allowedContentTypes.includes(contentType) - ) { - return res.status(400).json({ error: "Invalid content type" }); - } - - const credentials = - env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY - ? { - accessKeyId: env.S3_ACCESS_KEY_ID, - secretAccessKey: env.S3_SECRET_ACCESS_KEY, - } - : undefined; - - const client = new S3Client({ - region: env.S3_REGION ?? "", - endpoint: env.S3_ENDPOINT ?? "", - forcePathStyle: env.S3_FORCE_PATH_STYLE === "true", - credentials, - }); - - const signedUrl = await getSignedUrl( - client, - new PutObjectCommand({ - Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", - Key: filename, - ACL: "public-read", - }), - ); - - return res.status(200).json({ url: signedUrl, key: filename }); - } catch (error) { - return res.status(500).json({ error: (error as Error).message }); - } - }, -); diff --git a/apps/web/src/views/card/components/AttachmentUpload.tsx b/apps/web/src/views/card/components/AttachmentUpload.tsx index a0530a8b..3547bbc1 100644 --- a/apps/web/src/views/card/components/AttachmentUpload.tsx +++ b/apps/web/src/views/card/components/AttachmentUpload.tsx @@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge"; import Button from "~/components/Button"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; +import { env } from "next-runtime-env"; import { api } from "~/utils/api"; import { invalidateCard } from "~/utils/cardInvalidation"; @@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) { const [isDragging, setIsDragging] = useState(false); const inputRef = useRef(null); - const generateUploadUrl = api.attachment.generateUploadUrl.useMutation(); - const confirmAttachment = api.attachment.confirm.useMutation({ - onSuccess: async () => { + const uploadFile = async (file: File) => { + setUploading(true); + + try { + const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? ""; + const response = await fetch( + `${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`, + { + method: "POST", + headers: { + "Content-Type": file.type, + "x-original-filename": file.name, + }, + body: file, + }, + ); + + if (!response.ok) { + throw new Error("Upload failed"); + } + await invalidateCard(utils, cardPublicId); showPopup({ header: t`Attachment uploaded`, message: t`Your file has been uploaded successfully.`, icon: "success", }); - }, - onError: () => { - showPopup({ - header: t`Upload failed`, - message: t`Failed to upload attachment. Please try again.`, - icon: "error", - }); - }, - onSettled: () => { - setUploading(false); - }, - }); - - const uploadFile = async (file: File) => { - setUploading(true); - - try { - // Generate presigned URL - const { url, key } = await generateUploadUrl.mutateAsync({ - cardPublicId, - filename: file.name, - contentType: file.type, - size: file.size, - }); - - // Upload file to S3 - const uploadResponse = await fetch(url, { - method: "PUT", - body: file, - headers: { - "Content-Type": file.type, - }, - }); - - if (!uploadResponse.ok) { - throw new Error("Upload failed"); - } - - // Confirm attachment in database - await confirmAttachment.mutateAsync({ - cardPublicId, - s3Key: key, - filename: file.name, - originalFilename: file.name, - contentType: file.type, - size: file.size, - }); } catch { showPopup({ header: t`Upload failed`,