From 7adfa7926d510f9f5b9e707a03d3fd5936ea986b 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 | 3 + .../20240401001004_add_pfps/migration.sql | 22 ++++++ .../migration.sql | 8 +++ modules/users/db/schema.prisma | 24 +++++-- modules/users/module.yaml | 15 ++++ modules/users/scripts/create_user.ts | 3 +- modules/users/scripts/get_user.ts | 8 ++- modules/users/scripts/set_pfp.ts | 71 +++++++++++++++++++ modules/users/scripts/start_pfp_upload.ts | 55 ++++++++++++++ modules/users/tests/pfp.ts | 45 ++++++++++++ modules/users/utils/pfp.ts | 49 +++++++++++++ modules/users/utils/types.ts | 1 + tests/basic/backend.yaml | 2 + 13 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 modules/users/config.ts create mode 100644 modules/users/db/migrations/20240401001004_add_pfps/migration.sql create mode 100644 modules/users/db/migrations/20240401002555_remove_file_id/migration.sql create mode 100644 modules/users/scripts/set_pfp.ts create mode 100644 modules/users/scripts/start_pfp_upload.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..bba3b22c --- /dev/null +++ b/modules/users/config.ts @@ -0,0 +1,3 @@ +export interface Config { + maxPfpBytes: number; +} diff --git a/modules/users/db/migrations/20240401001004_add_pfps/migration.sql b/modules/users/db/migrations/20240401001004_add_pfps/migration.sql new file mode 100644 index 00000000..1b6cce71 --- /dev/null +++ b/modules/users/db/migrations/20240401001004_add_pfps/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "pfpId" UUID; + +-- CreateTable +CREATE TABLE "Pfp" ( + "uploadId" UUID NOT NULL, + "fileId" UUID NOT NULL, + "url" TEXT NOT NULL, + "urlExpiry" TIMESTAMP(3) NOT NULL, + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "finishedAt" TIMESTAMP(3), + + CONSTRAINT "Pfp_pkey" PRIMARY KEY ("uploadId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Pfp_userId_key" ON "Pfp"("userId"); + +-- AddForeignKey +ALTER TABLE "Pfp" ADD CONSTRAINT "Pfp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/modules/users/db/migrations/20240401002555_remove_file_id/migration.sql b/modules/users/db/migrations/20240401002555_remove_file_id/migration.sql new file mode 100644 index 00000000..d61d3ce8 --- /dev/null +++ b/modules/users/db/migrations/20240401002555_remove_file_id/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `fileId` on the `Pfp` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Pfp" DROP COLUMN "fileId"; diff --git a/modules/users/db/schema.prisma b/modules/users/db/schema.prisma index 16bdf151..ee5093d6 100644 --- a/modules/users/db/schema.prisma +++ b/modules/users/db/schema.prisma @@ -4,8 +4,24 @@ datasource db { } model User { - id String @id @default(uuid()) @db.Uuid - username String @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) @db.Uuid + username String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + pfpId String? @db.Uuid + pfp Pfp? +} + +model Pfp { + uploadId String @id @db.Uuid + url String + urlExpiry DateTime + + userId String @db.Uuid @unique + user User @relation(fields: [userId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + finishedAt DateTime? } diff --git a/modules/users/module.yaml b/modules/users/module.yaml index 46b02ddb..b4441666 100644 --- a/modules/users/module.yaml +++ b/modules/users/module.yaml @@ -11,6 +11,7 @@ status: stable dependencies: rate_limit: {} tokens: {} + uploads: {} scripts: get_user: name: Get User @@ -24,8 +25,22 @@ scripts: create_user_token: name: Create User Token description: Create a token for a user to authenticate future requests. + set_pfp: + name: Set Profile Picture + description: Set the profile picture for a user. + public: true + start_pfp_upload: + name: Start Profile Picture Upload + description: Allow the user to begin uploading a profile picture. + public: true errors: token_not_user_token: name: Token Not User Token unknown_identity_type: name: Unknown Identity Type + invalid_mime_type: + name: Invalid MIME Type + description: The MIME type for the supposed PFP isn't an image + file_too_large: + name: File Too Large + description: The file is larger than the configured maximum size for a profile picture diff --git a/modules/users/scripts/create_user.ts b/modules/users/scripts/create_user.ts index 594deb61..c19e8475 100644 --- a/modules/users/scripts/create_user.ts +++ b/modules/users/scripts/create_user.ts @@ -1,4 +1,5 @@ import { ScriptContext } from "../_gen/scripts/create_user.ts"; +import { withPfpUrl } from "../utils/pfp.ts"; import { User } from "../utils/types.ts"; export interface Request { @@ -23,7 +24,7 @@ export async function run( }); return { - user, + user: await withPfpUrl(ctx, user), }; } diff --git a/modules/users/scripts/get_user.ts b/modules/users/scripts/get_user.ts index 9e6dbe20..4a694295 100644 --- a/modules/users/scripts/get_user.ts +++ b/modules/users/scripts/get_user.ts @@ -1,5 +1,6 @@ import { ScriptContext } from "../_gen/scripts/get_user.ts"; import { User } from "../utils/types.ts"; +import { withPfpUrl } from "../utils/pfp.ts"; export interface Request { userIds: string[]; @@ -20,5 +21,10 @@ export async function run( orderBy: { username: "desc" }, }); - return { users }; + + const usersWithPfps = await Promise.all(users.map( + user => withPfpUrl(ctx, user), + )); + + return { users: usersWithPfps }; } diff --git a/modules/users/scripts/set_pfp.ts b/modules/users/scripts/set_pfp.ts new file mode 100644 index 00000000..2c570cc1 --- /dev/null +++ b/modules/users/scripts/set_pfp.ts @@ -0,0 +1,71 @@ +import { ScriptContext, RuntimeError } from "../_gen/scripts/set_pfp.ts"; +import { User } from "../utils/types.ts"; +import { withPfpUrl } 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 oldPfp = await db.pfp.findFirst({ + where: { userId }, + select: { uploadId: true }, + }); + if (oldPfp) { + await ctx.modules.uploads.delete({ uploadId: oldPfp.uploadId }); + await db.pfp.delete({ where: { userId } }); + } + + // Assign the new profile picture to the user + await db.pfp.create({ + data: { + userId, + uploadId: req.uploadId, + url: "", + urlExpiry: new Date(0).toISOString(), + finishedAt: new Date().toISOString(), + }, + }); + + // Get the new user object + const user = await db.user.findFirst({ + where: { id: userId }, + select: { + id: true, + username: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!user) { + throw new RuntimeError("internal_error", { cause: "User not found" }); + } + + return await withPfpUrl( + ctx, + user, + ); + }); + + return { user }; +} + diff --git a/modules/users/scripts/start_pfp_upload.ts b/modules/users/scripts/start_pfp_upload.ts new file mode 100644 index 00000000..05005d23 --- /dev/null +++ b/modules/users/scripts/start_pfp_upload.ts @@ -0,0 +1,55 @@ +import { ScriptContext, RuntimeError } from "../_gen/scripts/start_pfp_upload.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 + if (!req.mime.startsWith("image/")) { + throw new RuntimeError( + "invalid_mime_type", + { cause: `MIME type ${req.mime} is not an image` }, + ); + } + + // Ensure the file is within the maximum configured size for a PFP + if (BigInt(req.contentLength) > ctx.userConfig.maxPfpBytes) { + 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: `pfp/${userId}`, + contentLength: req.contentLength, + mime: req.mime, + multipart: false, + }, + ], + }); + + return { + url: presigned.files[0].presignedUrls[0].url, + uploadId: presigned.id, + } +} + diff --git a/modules/users/tests/pfp.ts b/modules/users/tests/pfp.ts new file mode 100644 index 00000000..817f8451 --- /dev/null +++ b/modules/users/tests/pfp.ts @@ -0,0 +1,45 @@ +import { test, TestContext } from "../_gen/test.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"; +import { decodeBase64 } from "https://deno.land/std@0.217.0/encoding/base64.ts"; + +const testPfp = "Qk2KBAAAAAAAAIoAAAB8AAAAEAAAAPD///8BACAAAwAAAAAEAAATCwAAEwsAAAAAAAAAAAAAAAD/AAD/AAD/AAAAAAAA/0JHUnMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAARUVFGkRERDxFRUVKRUVFUURERFJERERSRUVFUUVFRUpEREQ8RUVFGgAAAAAAAAAAAAAAAgAAAABAQEAIQ0NDm0NDQ/JFRUX/RkZG/0dHR/9HR0f/R0dH/0dHR/9HR0f/RUVF/0NDQ/JDQ0ObQEBACAAAAAAAAAAAQEBAnEhISP9BQUH/Q0ND/0tLS/1ISEj9RERE/UpKSv1KSkr9RERE/UBAQP9CQkL/SEhI/0FBQZwAAAAAOzs7Gj4+PvI+Pj7/Pz8/+jg4OP4AAAD+AAAA/i4uLv4AAAD+AAAA/jk5Of4/Pz/+PT09+j4+Pv8+Pj7yOzs7Gjw8PDw8PDz/PT09/zMzM/5PT0//p6en/5qamv9ubm7/ra2t/5+fn/9GRkb/MzMz/z09Pf47Ozv/PDw8/zw8PDw3NzdKOjo6/0JCQv0AAAD+j4+P///////k5OT/oqKi////////////wsLC/wAAAP8/Pz/+ODg4/To6Ov83NzdKNjY2UTc3N/89PT39AAAA/o6Ojv/29vb/09PT/5ubm//29vb/+vr6/9LS0v8AAAD/Ojo6/jY2Nv03Nzf/NjY2UTIyMlI0NDT/Ojo6/QAAAP6Hh4f/7e3t/8/Pz/99fX3/wsLC/7q6uv9lZWX/Hx8f/zY2Nv4xMTH9NDQ0/zIyMlIvLy9SMTEx/zc3N/0AAAD+gYGB/+Pj4//Jycn/WVlZ/5iYmP+YmJj/YWFh/xkZGf8zMzP+Li4u/TExMf8vLy9SLCwsUS4uLv80NDT9AAAA/nx8fP/Y2Nj/wMDA/z8/P//ExMT/4uLi/8PDw/8zMzP/IyMj/jAwMP0uLi7/LCwsUSkpKUoqKir/MTEx/QAAAP5ycnL/0dHR/7+/v/8AAAD/ZmZm/8fHx//S0tL/l5eX/wAAAP4wMDD9Kioq/ykpKUomJiY8JiYm/ygoKP8gICD+NjY2/3t7e/91dXX/LCws/xEREf9paWn/goKC/3R0dP8kJCT+JiYm/ycnJ/8mJiY8JycnGiMjI/IjIyP/JSUl+h0dHf4AAAD+AAAA/iIiIv4nJyf+AAAA/gAAAP4AAAD+JCQk+iMjI/8jIyPyJiYmGwAAAAAhISGcIyMj/yAgIP8iIiL/Kioq/SgoKP0gICD9ICAg/SgoKP0qKir9Jycn/yAgIP8jIyP/ISEhnAAAAAAAAAAAICAgCB4eHpseHh7yHR0d/x0dHf8eHh7/Hh4e/x4eHv8eHh7/HR0d/x0dHf8cHBzyHh4emyAgIAgAAAAAAAAAAgAAAAAAAAAAHR0dGhoaGjwcHBxKHBwcURwcHFIcHBxSHBwcURwcHEoaGho8HR0dGgAAAAAAAAAAAAAAAg==" +const testPfpArr = decodeBase64(testPfp); + +test("e2e", async (ctx: TestContext) => { + 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.startPfpUpload({ + mime: "image/bmp", + contentLength: atob(testPfp).length.toString(), + userToken: token.token, + }); + + // Upload the profile picture + await fetch(url, { + method: "PUT", + body: testPfpArr, + }); + + // Set the profile picture + await ctx.modules.users.setPfp({ + uploadId, + userToken: token.token, + }); + + // Get PFP from URL + const { users: [{ pfpUrl }] } = await ctx.modules.users.getUser({ userIds: [user.id] }); + assertExists(pfpUrl); + + // Get PFP from URL + const getPfpFromUrl = await fetch(pfpUrl); + const pfp = new Uint8Array(await getPfpFromUrl.arrayBuffer()); + assertEquals(pfp, testPfpArr); +}); diff --git a/modules/users/utils/pfp.ts b/modules/users/utils/pfp.ts new file mode 100644 index 00000000..f8996a2f --- /dev/null +++ b/modules/users/utils/pfp.ts @@ -0,0 +1,49 @@ +import { ModuleContext } from "../_gen/mod.ts"; +import { User } from "./types.ts"; + +const PRE_EXPIRY_BUFFER = 1000 * 60 * 60; // 1 hour +const EXPIRY_TIME = 1000 * 60 * 60 * 24 * 7; // 1 week + +export const refreshPfpUrl = async (ctx: ModuleContext, userId: string) => { + const pfp = await ctx.db.pfp.findFirst({ + where: { + userId, + }, + }); + + if (!pfp) { + return null; + } + + if (Date.now() + PRE_EXPIRY_BUFFER > pfp.urlExpiry.getTime()) { + const reqTime = Date.now(); + + const path = `pfp/${userId}`; + const { files: [{ url }] } = await ctx.modules.uploads.getPublicFileUrls({ + files: [{ uploadId: pfp.uploadId, path }], + validSecs: EXPIRY_TIME / 1000, + }); + + await ctx.db.pfp.update({ + where: { + userId, + }, + data: { + url, + urlExpiry: new Date(reqTime + EXPIRY_TIME), + }, + }); + + return url; + } else { + return pfp.url; + } +}; + +export const withPfpUrl = async (ctx: T, user: Omit) => { + const url = await refreshPfpUrl(ctx, user.id); + return { + ...user, + pfpUrl: url, + }; +} diff --git a/modules/users/utils/types.ts b/modules/users/utils/types.ts index 78c900cb..c8df33cc 100644 --- a/modules/users/utils/types.ts +++ b/modules/users/utils/types.ts @@ -3,4 +3,5 @@ export interface User { username: string; createdAt: Date; updatedAt: Date; + pfpUrl: string | null; } diff --git a/tests/basic/backend.yaml b/tests/basic/backend.yaml index f302704b..44ca035f 100644 --- a/tests/basic/backend.yaml +++ b/tests/basic/backend.yaml @@ -13,6 +13,8 @@ modules: registry: local users: registry: local + config: + maxPfpBytes: 1048576 # 1 MiB uploads: registry: local config: