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
36 changes: 24 additions & 12 deletions apps/mobile/src/components/ProfileImageUploader/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,9 @@ export class ProfileImageUploadError extends Error {
* the Maestro placeholder skip affordance (`shouldOfferMaestroPlaceholder`).
*
* Steps:
* 1. presign — request an upload descriptor from the API: method, url,
* headers to send, and the object's canonical public URL
* 2. compress — re-encode to WEBP @ 0.8 quality (expensive, kept after the
* caller has already shown optimistic visual feedback)
* 1. compress — re-encode to WEBP @ 0.8 quality
* 2. presign — send the exact byte length and request an upload descriptor:
* method, url, headers, and the object's canonical public URL
* 3. upload — send the bytes exactly as the descriptor says, via
* expo-file-system's BINARY_CONTENT mode
* 4. finalize — return the descriptor's `publicUrl`
Expand All @@ -264,9 +263,29 @@ export const uploadProfileImage = async (
localUri: string,
onProgress?: (stage: ProfileImageUploadStage) => void,
): Promise<string> => {
onProgress?.("compress");
const compressedImage = await compressImage(localUri).catch((error) => {
throw new ProfileImageUploadError("compress", "compressImage failed", {
cause: error,
});
});
const compressedImageInfo = await getInfoAsync(compressedImage.uri);
if (
!compressedImageInfo.exists ||
typeof compressedImageInfo.size !== "number"
) {
throw new ProfileImageUploadError(
"compress",
"Compressed photo size is unavailable",
);
}

onProgress?.("presign");
const upload = await getTrcpContext()
.image.signedUpload.fetch()
.image.signedUpload.fetch({
contentLength: compressedImageInfo.size,
contentType: "image/webp",
})
.catch((error) => {
throw new ProfileImageUploadError(
"presign",
Expand All @@ -275,13 +294,6 @@ export const uploadProfileImage = async (
);
});

onProgress?.("compress");
const compressedImage = await compressImage(localUri).catch((error) => {
throw new ProfileImageUploadError("compress", "compressImage failed", {
cause: error,
});
});

onProgress?.("upload");
const response = await uploadAsync(upload.url, compressedImage.uri, {
mimeType: getMimeType(compressedImage.uri),
Expand Down
10 changes: 10 additions & 0 deletions apps/nextjs/src/app/api/queues/cleanup-upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ICleanupUploadJobData } from "@pegada/api/queue/topics";

import { handleCleanupUpload } from "@pegada/api/queue/handlers/upload";
import { handleCallback } from "@vercel/queue";

const handler = handleCallback(async (message: ICleanupUploadJobData) => {
await handleCleanupUpload(message);
});

export const POST = (request: Request): Promise<Response> => handler(request);
2 changes: 2 additions & 0 deletions packages/api/src/queue/enqueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const INLINE_HANDLERS: {
import("./handlers/push").then((m) => m.handleSendPushNotification),
[TOPICS.CHECK_PUSH_RECEIPTS]: () =>
import("./handlers/push").then((m) => m.handleCheckPushReceipts),
[TOPICS.CLEANUP_UPLOAD]: () =>
import("./handlers/upload").then((m) => m.handleCleanupUpload),
};

const isVercelQueueAvailable = () =>
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/queue/handlers/process-image.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { IProcessImageJobData } from "../topics";

import { MAX_IMAGE_BYTES } from "@pegada/shared/constants/constants";
import { IMAGE_STATUS } from "@pegada/shared/schemas/dog-schema";

import { sendError } from "../../errors/errors";
import { ImageProcessingService } from "../../services/image-processing-service";
import { ImageService } from "../../services/image-service";
import { assertAllowedImageUrl } from "../../shared/image-url";

export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export { MAX_IMAGE_BYTES };
const MAX_REDIRECTS = 5;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);

Expand Down
48 changes: 48 additions & 0 deletions packages/api/src/queue/handlers/upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ImageService } from "../../services/image-service";
import { enqueue } from "../enqueue";
import { TOPICS } from "../topics";
import { handleCleanupUpload } from "./upload";

jest.mock("../../services/image-service", () => ({
ImageService: {
cleanupUploadGrant: jest.fn(async () => undefined),
cleanupExpiredTemporaryUpload: jest.fn(async () => undefined),
pruneUploadGrant: jest.fn(async () => undefined),
},
UPLOAD_GRANT_WINDOW_SECONDS: 3600,
}));

jest.mock("../enqueue", () => ({
enqueue: jest.fn(async () => undefined),
}));

const cleanupUploadGrant = jest.mocked(ImageService.cleanupUploadGrant);
const cleanupExpiredTemporaryUpload = jest.mocked(
ImageService.cleanupExpiredTemporaryUpload,
);
const pruneUploadGrant = jest.mocked(ImageService.pruneUploadGrant);
const enqueueJob = jest.mocked(enqueue);

it("deletes stale objects before scheduling grant pruning", async () => {
await handleCleanupUpload({ grantId: "grant-1", phase: "object" });

expect(cleanupExpiredTemporaryUpload).toHaveBeenCalledWith("grant-1");
expect(cleanupUploadGrant).not.toHaveBeenCalled();
expect(enqueueJob).toHaveBeenCalledWith(
TOPICS.CLEANUP_UPLOAD,
{ grantId: "grant-1", phase: "record" },
{
delaySeconds: 3600,
idempotencyKey: "upload-prune:grant-1",
},
);
});

it("prunes the grant record after its rate-limit window", async () => {
await handleCleanupUpload({ grantId: "grant-2", phase: "record" });

expect(cleanupUploadGrant).toHaveBeenCalledWith("grant-2");
expect(cleanupExpiredTemporaryUpload).not.toHaveBeenCalled();
expect(pruneUploadGrant).toHaveBeenCalledWith("grant-2");
expect(enqueueJob).not.toHaveBeenCalled();
});
29 changes: 29 additions & 0 deletions packages/api/src/queue/handlers/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ICleanupUploadJobData } from "../topics";

import {
ImageService,
UPLOAD_GRANT_WINDOW_SECONDS,
} from "../../services/image-service";
import { enqueue } from "../enqueue";
import { TOPICS } from "../topics";

export const handleCleanupUpload = async ({
grantId,
phase,
}: ICleanupUploadJobData) => {
if (phase === "record") {
await ImageService.cleanupUploadGrant(grantId);
await ImageService.pruneUploadGrant(grantId);
return;
}

await ImageService.cleanupExpiredTemporaryUpload(grantId);
await enqueue(
TOPICS.CLEANUP_UPLOAD,
{ grantId, phase: "record" },
{
delaySeconds: UPLOAD_GRANT_WINDOW_SECONDS,
idempotencyKey: `upload-prune:${grantId}`,
},
);
};
7 changes: 7 additions & 0 deletions packages/api/src/queue/topics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const TOPICS = {
PROCESS_IMAGE: "process-image",
SEND_PUSH: "send-push",
CHECK_PUSH_RECEIPTS: "check-push-receipts",
CLEANUP_UPLOAD: "cleanup-upload",
} as const;

export type Topic = (typeof TOPICS)[keyof typeof TOPICS];
Expand All @@ -26,9 +27,15 @@ export type ICheckPushNotificationReceiptsJobData = {
receipts?: { id: string; pushToken: string }[];
};

export type ICleanupUploadJobData = {
grantId: string;
phase: "object" | "record";
};

export type TopicPayloads = {
[TOPICS.MAIL]: IMailJobData;
[TOPICS.PROCESS_IMAGE]: IProcessImageJobData;
[TOPICS.SEND_PUSH]: ISendNotificationJobData;
[TOPICS.CHECK_PUSH_RECEIPTS]: ICheckPushNotificationReceiptsJobData;
[TOPICS.CLEANUP_UPLOAD]: ICleanupUploadJobData;
};
13 changes: 8 additions & 5 deletions packages/api/src/routes/image.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ImageService } from "../services/image-service";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { signedUploadInputSchema } from "./input-schemas";

export const imageRouter = createTRPCRouter({
/**
Expand All @@ -8,13 +9,15 @@ export const imageRouter = createTRPCRouter({
* backing storage (S3) are frozen until those binaries are sunset via
* MIN_APP_VERSION. New code uses `signedUpload`.
*/
signedUrl: protectedProcedure.query(async () => {
const presignedUrl = await ImageService.getSignedUrl();
signedUrl: protectedProcedure.query(async ({ ctx }) => {
const presignedUrl = await ImageService.getSignedUrl(ctx.session.user.id);
return presignedUrl.url;
}),

/** Storage-agnostic upload descriptor — see `SignedUpload` in ImageService. */
signedUpload: protectedProcedure.query(() => {
return ImageService.getSignedUpload();
}),
signedUpload: protectedProcedure
.input(signedUploadInputSchema.optional())
.query(({ ctx, input }) =>
ImageService.getSignedUpload(ctx.session.user.id, input),
),
});
6 changes: 6 additions & 0 deletions packages/api/src/routes/input-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { MAX_IMAGE_BYTES } from "@pegada/shared/constants/constants";
import { z } from "zod";

export const signedUploadInputSchema = z.object({
contentLength: z.number().int().min(1).max(MAX_IMAGE_BYTES),
contentType: z.literal("image/webp"),
});

export const messageListInputSchema = z.object({
matchId: z.string().uuid(),
limit: z.coerce.number().int().min(1).max(100).optional().default(10),
Expand Down
26 changes: 26 additions & 0 deletions packages/api/src/routes/input-validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { MAX_IMAGE_BYTES } from "@pegada/shared/constants/constants";

import {
messageListInputSchema,
messageSendInputSchema,
signedUploadInputSchema,
swipeQueryInputSchema,
} from "./input-schemas";

Expand Down Expand Up @@ -57,3 +60,26 @@ describe("swipe input limits", () => {
).toBe(false);
});
});

describe("upload input limits", () => {
it("accepts only bounded WEBP uploads", () => {
expect(
signedUploadInputSchema.safeParse({
contentLength: MAX_IMAGE_BYTES,
contentType: "image/webp",
}).success,
).toBe(true);
expect(
signedUploadInputSchema.safeParse({
contentLength: MAX_IMAGE_BYTES + 1,
contentType: "image/webp",
}).success,
).toBe(false);
expect(
signedUploadInputSchema.safeParse({
contentLength: 1024,
contentType: "text/html",
}).success,
).toBe(false);
});
});
33 changes: 33 additions & 0 deletions packages/api/src/services/dog-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import prisma from "@pegada/database";
import { Gender } from "@prisma/client";

import { config } from "../shared/config";
import { deleteImageFromS3 } from "../shared/file-upload";
import { DogService } from "./dog-service";

// `enqueue` pulls in `errors.ts` -> `observability.ts` -> the ESM-only
Expand All @@ -12,11 +14,25 @@ jest.mock("../errors/errors", () => ({
errorDebug: () => undefined,
}));

jest.mock("../shared/file-upload", () => {
const actual = jest.requireActual<typeof import("../shared/file-upload")>(
"../shared/file-upload",
);

return {
...actual,
deleteImageFromS3: jest.fn(async () => undefined),
};
});

const deleteStoredImage = jest.mocked(deleteImageFromS3);

afterAll(async () => {
await prisma.$disconnect();
});

beforeEach(async () => {
await prisma.uploadGrant.deleteMany();
await prisma.message.deleteMany();
await prisma.match.deleteMany();
await prisma.interest.deleteMany();
Expand Down Expand Up @@ -66,3 +82,20 @@ describe("DogService.updateDog", () => {
).resolves.toMatchObject({ position: 0 });
});
});

describe("DogService.deleteDog", () => {
it("deletes the public object before removing its database row", async () => {
const storedUrl = `https://${config.AWS_S3_BUCKET_NAME}.s3.${config.AWS_REGION}.amazonaws.com/dogs/delete.webp`;
const dog = await seedDog("delete-image-owner@pegada.app", storedUrl);

await DogService.deleteDog(dog.id);

expect(deleteStoredImage).toHaveBeenCalledWith(storedUrl);
await expect(
prisma.image.count({ where: { dogId: dog.id } }),
).resolves.toBe(0);
await expect(
prisma.dog.findUniqueOrThrow({ where: { id: dog.id } }),
).resolves.toMatchObject({ deletedAt: expect.any(Date) });
});
});
26 changes: 23 additions & 3 deletions packages/api/src/services/dog-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ export class DogService {
}

const nonEmptyImages = dogInput.images.filter((image) => image.url);
const images =
await ImageService.makeTemporaryImagesPermanent(nonEmptyImages);
const images = await ImageService.makeTemporaryImagesPermanent(
nonEmptyImages,
dogInput.userId,
);

const dog = await prisma.dog.create({
data: {
Expand All @@ -103,6 +105,10 @@ export class DogService {
}

static async updateDog(id: string, dogInput: Partial<DogServerSchema>) {
const dog = await prisma.dog.findUniqueOrThrow({
where: { id },
select: { userId: true },
});
const existingImages = dogInput.images
? await prisma.image.findMany({ where: { dogId: id } })
: [];
Expand All @@ -111,7 +117,10 @@ export class DogService {
this.#classifyImages(existingImages, dogInput.images ?? []);

const imagesToCreatePermanent =
await ImageService.makeTemporaryImagesPermanent(imagesToCreate);
await ImageService.makeTemporaryImagesPermanent(
imagesToCreate,
dog.userId,
);

const dogTransaction = await prisma.$transaction([
...imagesToUpdate.map((image) =>
Expand Down Expand Up @@ -243,6 +252,17 @@ export class DogService {
}

static async deleteDog(id: string) {
const imageUrls = await prisma.image.findMany({
where: { dogId: id },
select: { url: true },
});

await Promise.all(
imageUrls
.filter(({ url }) => isAllowedImageUrl(url))
.map(({ url }) => deleteImageFromS3(url)),
);

// Cascade soft-delete
await prisma.$transaction([
prisma.dog.update({
Expand Down
Loading
Loading