From 54abdfd662ce147f0fcdbc8ed660bb0e55d82e88 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Sun, 31 Mar 2024 20:42:23 -0400 Subject: [PATCH] feat(users): `users` profile pictures --- modules/users/config.ts | 9 +++ .../users/scripts/prepare_profile_picture.ts | 56 ++++++++++++++ modules/users/scripts/set_profile_picture.ts | 74 +++++++++++++++++++ modules/users/tests/pfp.ts | 45 +++++++++++ modules/users/utils/pfp.ts | 43 +++++++++++ 5 files changed, 227 insertions(+) create mode 100644 modules/users/config.ts create mode 100644 modules/users/scripts/prepare_profile_picture.ts create mode 100644 modules/users/scripts/set_profile_picture.ts create mode 100644 modules/users/tests/pfp.ts create mode 100644 modules/users/utils/pfp.ts diff --git a/modules/users/config.ts b/modules/users/config.ts new file mode 100644 index 00000000..4a007119 --- /dev/null +++ b/modules/users/config.ts @@ -0,0 +1,9 @@ +export interface Config { + maxProfilePictureBytes: number; + allowedMimes?: string[]; +} + +export const DEFAULT_MIME_TYPES = [ + "image/jpeg", + "image/png", +]; diff --git a/modules/users/scripts/prepare_profile_picture.ts b/modules/users/scripts/prepare_profile_picture.ts new file mode 100644 index 00000000..84305295 --- /dev/null +++ b/modules/users/scripts/prepare_profile_picture.ts @@ -0,0 +1,56 @@ +import { ScriptContext, RuntimeError } from "../module.gen.ts"; +import { DEFAULT_MIME_TYPES } from "../config.ts"; + +export interface Request { + mime: string; + contentLength: string; + userToken: string; +} + +export interface Response { + url: string; + uploadId: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Authenticate/rate limit because this is a public route + await ctx.modules.rateLimit.throttlePublic({ period: 60, requests: 5 }); + const { userId } = await ctx.modules.users.authenticateUser({ userToken: req.userToken }); + + // Ensure at least the MIME type says it is an image + const allowedMimes = ctx.userConfig.allowedMimes ?? DEFAULT_MIME_TYPES; + if (!allowedMimes.includes(req.mime)) { + throw new RuntimeError( + "invalid_mime_type", + { cause: `MIME type ${req.mime} is not an allowed image type` }, + ); + } + + // Ensure the file is within the maximum configured size for a PFP + if (BigInt(req.contentLength) > ctx.userConfig.maxProfilePictureBytes) { + throw new RuntimeError( + "file_too_large", + { cause: `File is too large (${req.contentLength} bytes)` }, + ); + } + + // Prepare the upload to get the presigned URL + const { upload: presigned } = await ctx.modules.uploads.prepare({ + files: [ + { + path: `profile-picture`, + contentLength: req.contentLength, + mime: req.mime, + multipart: false, + }, + ], + }); + + return { + url: presigned.files[0].presignedChunks[0].url, + uploadId: presigned.id, + } +} diff --git a/modules/users/scripts/set_profile_picture.ts b/modules/users/scripts/set_profile_picture.ts new file mode 100644 index 00000000..9b6e2a2f --- /dev/null +++ b/modules/users/scripts/set_profile_picture.ts @@ -0,0 +1,74 @@ +import { ScriptContext, RuntimeError } from "../module.gen.ts"; +import { User } from "../utils/types.ts"; +import { withPfpUrls } from "../utils/pfp.ts"; + +export interface Request { + uploadId: string; + userToken: string; +} + +export interface Response { + user: User; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Authenticate/rate limit because this is a public route + await ctx.modules.rateLimit.throttlePublic({ period: 60, requests: 5 }); + const { userId } = await ctx.modules.users.authenticateUser({ userToken: req.userToken }); + + // Complete the upload in the `uploads` module + await ctx.modules.uploads.complete({ uploadId: req.uploadId }); + + // Delete the old uploaded profile picture and replace it with the new one + const [user] = await ctx.db.$transaction(async (db) => { + // If there is an existing profile picture, delete it + const oldUser = await db.user.findFirst({ + where: { id: userId }, + }); + + // (This means that `users.authenticateUser` is broken!) + if (!oldUser) { + throw new RuntimeError( + "internal_error", + { + meta: "Existing user not found", + }, + ); + } + + if (oldUser.avatarUploadId) { + await ctx.modules.uploads.delete({ uploadId: oldUser.avatarUploadId }); + } + + // Update the user upload ID + const user = await db.user.update({ + where: { + id: userId, + }, + data: { + avatarUploadId: req.uploadId, + }, + select: { + id: true, + username: true, + avatarUploadId: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!user) { + throw new RuntimeError("internal_error", { cause: "User not found" }); + } + + return await withPfpUrls( + ctx, + [user], + ); + }); + + return { user }; +} diff --git a/modules/users/tests/pfp.ts b/modules/users/tests/pfp.ts new file mode 100644 index 00000000..48e32785 --- /dev/null +++ b/modules/users/tests/pfp.ts @@ -0,0 +1,45 @@ +import { test, TestContext } from "../module.gen.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; +import { assertEquals } from "https://deno.land/std@0.217.0/assert/assert_equals.ts"; +import { assertExists } from "https://deno.land/std@0.217.0/assert/assert_exists.ts"; + +test("e2e", async (ctx: TestContext) => { + const imageReq = await fetch("https://picsum.photos/200/300"); + const imageData = new Uint8Array(await imageReq.arrayBuffer()); + + + const { user } = await ctx.modules.users.createUser({ + username: faker.internet.userName(), + }); + + const { token } = await ctx.modules.users.createUserToken({ + userId: user.id, + }); + + const { url, uploadId } = await ctx.modules.users.prepareProfilePicture({ + mime: imageReq.headers.get("Content-Type") ?? "image/jpeg", + contentLength: imageData.length.toString(), + userToken: token.token, + }); + + // Upload the profile picture + await fetch(url, { + method: "PUT", + body: imageData, + }); + + // Set the profile picture + await ctx.modules.users.setProfilePicture({ + uploadId, + userToken: token.token, + }); + + // Get PFP from URL + const { users: [{ profilePictureUrl }] } = await ctx.modules.users.getUser({ userIds: [user.id] }); + assertExists(profilePictureUrl); + + // Get PFP from URL + const getPfpFromUrl = await fetch(profilePictureUrl); + const pfp = new Uint8Array(await getPfpFromUrl.arrayBuffer()); + assertEquals(pfp, imageData); +}); diff --git a/modules/users/utils/pfp.ts b/modules/users/utils/pfp.ts new file mode 100644 index 00000000..99e62d74 --- /dev/null +++ b/modules/users/utils/pfp.ts @@ -0,0 +1,43 @@ +import { ModuleContext } from "../module.gen.ts"; +import { User } from "./types.ts"; + +const EXPIRY_SECS = 60 * 60 * 24; // 1 day + +type UserWithUploadidInfo = Omit & { avatarUploadId: string | null }; +type FileRef = { uploadId: string; path: string }; + +function getFileRefs(users: UserWithUploadidInfo[]) { + const pairs: FileRef[] = []; + for (const { avatarUploadId: uploadId } of users) { + if (uploadId) { + pairs.push({ uploadId: uploadId, path: "profile-picture" }); + } + } + return pairs; +} + +export async function withPfpUrls( + ctx: T, + users: UserWithUploadidInfo[], +): Promise { + const fileRefs = getFileRefs(users); + + const { files } = await ctx.modules.uploads.getPublicFileUrls({ + files: fileRefs, + expirySeconds: EXPIRY_SECS, + }); + + const map = new Map(files.map((file) => [file.uploadId, file.url])); + + const completeUsers: User[] = []; + for (const user of users) { + if (user.avatarUploadId && map.has(user.avatarUploadId)) { + const profilePictureUrl = map.get(user.avatarUploadId)!; + completeUsers.push({ ...user, profilePictureUrl }); + } else { + completeUsers.push({ ...user, profilePictureUrl: null }); + } + } + + return completeUsers; +}