All files / apps/web/src/handlers/user change-avatar.ts

100% Statements 28/28
100% Branches 14/14
100% Functions 2/2
100% Lines 28/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83                6x   6x   6x 7x 1x         6x 6x 1x           5x 5x 1x         4x 1x         3x 1x         2x     6x               8x 8x 1x         1x   7x 7x     7x 7x 1x     1x         1x    
import { TRPCError } from "@trpc/server";
import { imageSize } from "image-size";
 
import { avatarFormSchema } from "~app/utils/validation";
import { MAX_AVATAR_BYTESIZE, MAX_AVATAR_SIDE_SIZE } from "~utils/images";
import { authProcedure } from "~web/handlers/trpc";
import { getS3Client } from "~web/providers/s3";
 
export const S3_AVATAR_PREFIX = "avatars";
 
const ALLOWED_FORMATS = new Set(["png", "jpg"]);
 
const validateImage = (image: Buffer) => {
	if (image.byteLength > MAX_AVATAR_BYTESIZE) {
		throw new TRPCError({
			code: "BAD_REQUEST",
			message: `Maximum bytesize allowed is ${MAX_AVATAR_BYTESIZE}.`,
		});
	}
	const metadata = imageSize(image);
	if (!metadata.type || !ALLOWED_FORMATS.has(metadata.type)) {
		throw new TRPCError({
			code: "BAD_REQUEST",
			/* c8 ignore next */
			message: `Format "${metadata.type || "unknown"}" is not allowed.`,
		});
	}
	const maxSizeAllowed = MAX_AVATAR_SIDE_SIZE;
	if (metadata.height > maxSizeAllowed) {
		throw new TRPCError({
			code: "BAD_REQUEST",
			message: `Maximum height allowed is ${maxSizeAllowed}.`,
		});
	}
	if (metadata.width > maxSizeAllowed) {
		throw new TRPCError({
			code: "BAD_REQUEST",
			message: `Maximum width allowed is ${maxSizeAllowed}.`,
		});
	}
	if (metadata.width !== metadata.height) {
		throw new TRPCError({
			code: "BAD_REQUEST",
			message: `Expected to have equal height and width, got ${metadata.width}x${metadata.height}.`,
		});
	}
	return image;
};
 
export const procedure = authProcedure
	.meta({
		title: "Change  user avatar",
		description:
			"Uploads and sets a new avatar image for the current  user, or clears it if no avatar is provided.",
	})
	.input(avatarFormSchema)
	.mutation(async ({ input, ctx }) => {
		const { database } = ctx;
		if (!input.avatar) {
			await database
				.updateTable("users")
				.set({ avatarUrl: null })
				.where("users.id", "=", ctx.auth.userId)
				.executeTakeFirst();
			return;
		}
		const s3Client = getS3Client(ctx);
		const validatedImage = validateImage(
			Buffer.from(await input.avatar.arrayBuffer()),
		);
		const avatarKey = [S3_AVATAR_PREFIX, `${ctx.auth.userId}.png`].join("/");
		await s3Client.putObject(avatarKey, validatedImage);
		const url = `${[s3Client.endpoint, s3Client.bucket, avatarKey].join(
			"/",
		)}?lastModified=${Temporal.Now.instant().epochMilliseconds}`;
		await database
			.updateTable("users")
			.set({ avatarUrl: url })
			.where("users.id", "=", ctx.auth.userId)
			.executeTakeFirst();
		return { url };
	});