refactor: replace presigned URL uploads with backend upload endpoints
This commit is contained in:
133
apps/web/src/pages/api/upload/attachment.ts
Normal file
133
apps/web/src/pages/api/upload/attachment.ts
Normal file
@@ -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" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
@@ -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" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||||
|
|
||||||
@@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
const uploadFile = async (file: File) => {
|
||||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
setUploading(true);
|
||||||
onSuccess: async () => {
|
|
||||||
|
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);
|
await invalidateCard(utils, cardPublicId);
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Attachment uploaded`,
|
header: t`Attachment uploaded`,
|
||||||
message: t`Your file has been uploaded successfully.`,
|
message: t`Your file has been uploaded successfully.`,
|
||||||
icon: "success",
|
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 {
|
} catch {
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Upload failed`,
|
header: t`Upload failed`,
|
||||||
|
|||||||
Reference in New Issue
Block a user