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
40 changes: 18 additions & 22 deletions app/api/listings/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
updateListing,
} from "@/services/listings/listings";
import { ListingInput } from "@/models/Listing";
import { uploadImage } from "@/lib/googleCloud";
import {
getValidImageFiles,
uploadListingImages,
} from "@/lib/listing-images";
import { getSession } from "@/lib/rbac";

const objectIdSchema = z
Expand Down Expand Up @@ -127,9 +130,13 @@ async function PUT(request: Request, { params }: { params: { id: string } }) {
}

const formData = await request.formData();
const updateData: Partial<ListingInput> = {
...Object.fromEntries(formData.entries()),
};
// Build a plain object from non-file fields before validation.
const textEntries: [string, FormDataEntryValue][] = [];
for (const [key, value] of formData.entries()) {
if (key === "image" || key === "images" || key === "hazardTags") continue;
textEntries.push([key, value]);
}
const updateData: Partial<ListingInput> = Object.fromEntries(textEntries);

// handle array fields
const hazardTags = formData.getAll("hazardTags");
Expand All @@ -152,9 +159,6 @@ async function PUT(request: Request, { params }: { params: { id: string } }) {
);
}

// handle image uploads if provided
const imageFiles = formData.getAll("images") as File[];

const parsedRequest = listingValidationSchema.safeParse(updateData);
if (!parsedRequest.success) {
return NextResponse.json(
Expand All @@ -166,23 +170,15 @@ async function PUT(request: Request, { params }: { params: { id: string } }) {
);
}

if (imageFiles.length > 0) {
const imageUrls: string[] = [];

for (const imageFile of imageFiles) {
const buffer = Buffer.from(await imageFile.arrayBuffer());
const imageUrl = await uploadImage(buffer, imageFile.name);
imageUrls.push(imageUrl);
try {
// Only replace image URLs when real files were uploaded in this request.
const updatePayload = { ...parsedRequest.data };
const imageFiles = getValidImageFiles(formData);
if (imageFiles.length > 0) {
updatePayload.imageUrls = await uploadListingImages(imageFiles);
}

updateData.imageUrls = imageUrls;
}

try {
const updatedListing = await updateListing(
parsedId.data,
parsedRequest.data
);
const updatedListing = await updateListing(parsedId.data, updatePayload);

if (!updatedListing) {
return NextResponse.json(
Expand Down
56 changes: 54 additions & 2 deletions app/api/listings/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ describe("API: Successful Responses", () => {
const formData = new FormData();
Object.entries(listingData).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => formData.append(key, item));
value.forEach(item => formData.append(key, item));
} else {
formData.append(key, String(value));
}
Expand Down Expand Up @@ -216,6 +216,58 @@ describe("API: Successful Responses", () => {
);
});

test("creates listing when form includes an empty images part", async () => {
const date = new Date();
const minimalData = {
itemName: "Flask",
itemId: "item1",
labId: "lab1",
quantityAvailable: 5,
status: "ACTIVE",
condition: "New",
};

const mockReturnedData = {
...minimalData,
id: "123",
createdAt: date,
imageUrls: [],
labName: "",
labLocation: "",
description: "",
price: 0,
hazardTags: [],
};

const formData = new FormData();
formData.append(
"images",
new File([], "", { type: "application/octet-stream" })
);
Object.entries(minimalData).forEach(([key, value]) => {
formData.append(key, String(value));
});

(connectToDatabase as jest.Mock).mockResolvedValue({});
(addListing as jest.Mock).mockResolvedValue(mockReturnedData);

const req = new Request("http://localhost/api/listings", {
method: "POST",
body: formData,
});

const res = await POST(req);
const body = await res.json();

expect(res.status).toBe(201);
expect(body.success).toBe(true);
expect(addListing).toHaveBeenCalledWith(
expect.objectContaining({
imageUrls: [],
})
);
});

test("creates listing with defaults for optional fields", async () => {
const date = new Date();

Expand Down Expand Up @@ -305,7 +357,7 @@ describe("API: Successful Responses", () => {
Object.entries(listingData).forEach(([key, value]) => {
if (value === null || value === undefined) return;
if (Array.isArray(value)) {
value.forEach((item) => formData.append(key, item));
value.forEach(item => formData.append(key, item));
} else {
formData.append(key, String(value));
}
Expand Down
25 changes: 12 additions & 13 deletions app/api/listings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { connectToDatabase } from "@/lib/mongoose";
import { z } from "zod";
import { getFilteredListings, addListing } from "@/services/listings/listings";
import { ListingInput } from "@/models/Listing";
import { uploadImage } from "@/lib/googleCloud";
import {
getValidImageFiles,
uploadListingImages,
} from "@/lib/listing-images";
import { getSession } from "@/lib/rbac";

const listingValidationSchema = z.object({
Expand Down Expand Up @@ -143,7 +146,7 @@ async function POST(request: Request) {
const hazardTags: string[] = [];

for (const [key, value] of entries) {
if (key === "image") continue;
if (key === "image" || key === "images") continue;
if (key === "hazardTags") {
hazardTags.push(value as string);
} else {
Expand All @@ -164,8 +167,6 @@ async function POST(request: Request) {
result.expiryDate = new Date(result.expiryDate as unknown as string);
}

const imageFiles = formData.getAll("images") as File[];

const parsedBody = listingValidationSchema.safeParse(result);
if (!parsedBody.success) {
return NextResponse.json(
Expand All @@ -177,19 +178,17 @@ async function POST(request: Request) {
);
}

if (imageFiles.length > 0) {
const imageUrls: string[] = [];
for (const imageFile of imageFiles) {
const buffer = Buffer.from(await imageFile.arrayBuffer());
const imageUrl = await uploadImage(buffer, imageFile.name);
imageUrls.push(imageUrl);
try {
// Keep parsed defaults unless real image files were provided.
let imageUrls = parsedBody.data.imageUrls ?? [];
const imageFiles = getValidImageFiles(formData);
if (imageFiles.length > 0) {
imageUrls = await uploadListingImages(imageFiles);
}
result.imageUrls = imageUrls;
}

try {
const listingData = {
...parsedBody.data,
imageUrls,
createdAt: new Date(),
} as ListingInput;
const listing = await addListing(listingData);
Expand Down
17 changes: 16 additions & 1 deletion components/listings/ListingForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,29 @@ export function ListingForm({ initialValues, listingId }: ListingFormProps) {
);
}

/**
* Submits listing form data while omitting empty file inputs.
* This prevents browsers from sending a blank `images` part that could
* trigger unnecessary image-upload logic on the API.
*/
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setErrorMessage(null);

const formData = new FormData(event.currentTarget);
const form = event.currentTarget;
const formData = new FormData(form);

formData.delete("images");
formData.delete("hazardTags");
const fileInput = form.elements.namedItem("images") as HTMLInputElement | null;
if (fileInput?.files) {
Array.from(fileInput.files).forEach((file) => {
if (file.size > 0) {
formData.append("images", file);
}
});
}
hazards.forEach((tag) => formData.append("hazardTags", tag));

const endpoint = isEditMode ? `/api/listings/${listingId}` : "/api/listings";
Expand Down
23 changes: 23 additions & 0 deletions lib/__tests__/listing-images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getValidImageFiles } from "@/lib/listing-form-data";

describe("getValidImageFiles", () => {
it("ignores empty file parts from the browser", () => {
const formData = new FormData();
formData.append("images", new File([], "", { type: "application/octet-stream" }));
formData.append("itemName", "test");

expect(getValidImageFiles(formData)).toHaveLength(0);
});

it("keeps files with content and a name", () => {
const formData = new FormData();
formData.append(
"images",
new File([new Uint8Array([1])], "photo.png", { type: "image/png" })
);

const files = getValidImageFiles(formData);
expect(files).toHaveLength(1);
expect(files[0].name).toBe("photo.png");
});
});
33 changes: 23 additions & 10 deletions lib/googleCloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,42 @@ const bucketName = process.env.GOOGLE_CLOUD_BUCKET_NAME!;
const bucket = storage.bucket(bucketName);

/**
* Upload a publicly accessible image file to GCS
* @param file image as raw bytes of data
* @param originalFilename original name of the image file
* @returns a promise that resolves to the public URL of the uploaded image
* Infers a MIME type from filename extension for object metadata.
* Defaults to JPEG when extension is missing or unknown.
*/
function inferContentType(filename: string): string {
const lower = filename.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".gif")) return "image/gif";
return "image/jpeg";
}

/**
* Upload an image file to GCS.
* Public access is expected via bucket-level IAM (uniform bucket-level access);
* do not call per-object ACL APIs such as makePublic().
*/
export async function uploadImage(
file: Buffer,
originalFilename: string
): Promise<string> {
if (file.length === 0) {
throw new Error("Cannot upload an empty file.");
}

const timestamp = Date.now();
const safeFilename = originalFilename.replace(/\s/g, "_"); // replace spaces
const uniqueFilename = `listings/${timestamp}-${safeFilename}`; // unique by timestamp
const safeFilename =
originalFilename.replace(/\s/g, "_").trim() || "image";
const uniqueFilename = `listings/${timestamp}-${safeFilename}`;

const blob = bucket.file(uniqueFilename);

await blob.save(file, {
metadata: {
contentType: "image/jpeg",
contentType: inferContentType(safeFilename),
},
});

// Make the file publicly accessible
await blob.makePublic();

return `https://storage.googleapis.com/${bucketName}/${uniqueFilename}`;
}
11 changes: 11 additions & 0 deletions lib/listing-form-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Returns only real uploaded image files from a multipart form payload.
* Browsers may include an empty `images` part (filename="", size=0) even when
* the user did not pick a file, so those entries are filtered out.
* @param formData the data payload
*/
export function getValidImageFiles(formData: FormData): File[] {
return (formData.getAll("images") as File[]).filter(
file => file.size > 0 && file.name.trim() !== ""
);
}
17 changes: 17 additions & 0 deletions lib/listing-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { uploadImage } from "@/lib/googleCloud";

export { getValidImageFiles } from "@/lib/listing-form-data";

/**
* Uploads a list of validated browser `File` objects and returns their public
* URLs in the same order they were provided.
* @param files the files to upload
*/
export async function uploadListingImages(files: File[]): Promise<string[]> {
const imageUrls: string[] = [];
for (const imageFile of files) {
const buffer = Buffer.from(await imageFile.arrayBuffer());
imageUrls.push(await uploadImage(buffer, imageFile.name));
}
return imageUrls;
}
Loading