fix: address PR #102 review feedback (GRO-145)

- factories.ts: add photoKey/photoUploadedAt null defaults to buildPet (TS regression fix)
- s3.ts: lazy singleton S3Client to avoid re-instantiation per call
- routes/pets.ts: server-side 5MB file size limit, explicit content-type allowlist (drops image/svg+xml etc), validate confirm key ownership against pets/${petId}/ prefix, delete old S3 object on re-upload, fix RBAC comment on DELETE photo
- PetPhotoUpload.tsx: bypass canvas resize for GIFs (preserves animation), pass fileSizeBytes in upload-url request
- Add PetPhotoDisplay.test.tsx: 7 tests covering fetch states, placeholder, refetch on petId change, custom size
- Add PetPhotoUpload.test.tsx: 8 tests covering idle state, type validation, upload flow, progress, GIF bypass
- Update petPhotos.test.ts: add SVG rejection, 5MB limit, key ownership, and old-photo deletion tests (18 total)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Scrubs McBarkley
2026-03-22 15:41:44 +00:00
parent 1380848aea
commit 90abb28a0d
7 changed files with 518 additions and 22 deletions
+31 -8
View File
@@ -99,12 +99,22 @@ petsRouter.delete("/:id", async (c) => {
// ─── Photo routes ──────────────────────────────────────────────────────────────
const ALLOWED_CONTENT_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
const MAX_PHOTO_SIZE = 5 * 1024 * 1024; // 5 MB
const uploadUrlSchema = z.object({
contentType: z
.string()
.refine((v) => v.startsWith("image/"), {
message: "contentType must be an image/* MIME type",
}),
contentType: z.string().refine((v) => ALLOWED_CONTENT_TYPES.has(v), {
message: "contentType must be one of: image/jpeg, image/png, image/webp, image/gif",
}),
fileSizeBytes: z.number().int().positive().max(MAX_PHOTO_SIZE, {
message: "File must not exceed 5 MB",
}),
});
const confirmSchema = z.object({
@@ -122,14 +132,14 @@ petsRouter.post(
async (c) => {
const db = getDb();
const petId = c.req.param("petId");
const { contentType } = c.req.valid("json");
const { contentType, fileSizeBytes } = c.req.valid("json");
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
if (!pet) return c.json({ error: "Pet not found" }, 404);
const ext = contentType.split("/")[1] ?? "jpg";
const key = `pets/${petId}/${Date.now()}.${ext}`;
const uploadUrl = await getPresignedUploadUrl(key, contentType);
const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes);
return c.json({ uploadUrl, key });
}
@@ -148,6 +158,19 @@ petsRouter.post(
const petId = c.req.param("petId");
const { key } = c.req.valid("json");
// Validate that the key belongs to this pet to prevent key hijacking
if (!key.startsWith(`pets/${petId}/`)) {
return c.json({ error: "Invalid key" }, 400);
}
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
if (!pet) return c.json({ error: "Pet not found" }, 404);
// Delete the previous photo from storage to avoid orphaned objects
if (pet.photoKey) {
await deleteObject(pet.photoKey);
}
const [row] = await db
.update(pets)
.set({ photoKey: key, photoUploadedAt: new Date(), updatedAt: new Date() })
@@ -162,7 +185,7 @@ petsRouter.post(
/**
* DELETE /:petId/photo
* Removes the photo from object storage and clears the DB record.
* Manager-only (write-destructive operation).
* All staff roles (manager, receptionist, groomer) may call this.
*/
petsRouter.delete("/:petId/photo", async (c) => {
const db = getDb();