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/db/migrations/20240522003232_initial_setup/migration.sql b/modules/users/db/migrations/20240522003232_initial_setup/migration.sql new file mode 100644 index 00000000..ec8b0212 --- /dev/null +++ b/modules/users/db/migrations/20240522003232_initial_setup/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatarUploadId" UUID; diff --git a/modules/users/db/schema.prisma b/modules/users/db/schema.prisma index 16bdf151..c5bafd5c 100644 --- a/modules/users/db/schema.prisma +++ b/modules/users/db/schema.prisma @@ -4,8 +4,10 @@ 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 + + avatarUploadId String? @db.Uuid } diff --git a/modules/users/module.json b/modules/users/module.json index c21c8260..fb4c3390 100644 --- a/modules/users/module.json +++ b/modules/users/module.json @@ -8,12 +8,14 @@ ], "authors": [ "rivet-gg", - "NathanFlurry" + "NathanFlurry", + "Blckbrry-Pi" ], "status": "stable", "dependencies": { "rate_limit": {}, - "tokens": {} + "tokens": {}, + "uploads": {} }, "scripts": { "get_user": { @@ -31,6 +33,16 @@ "create_user_token": { "name": "Create User Token", "description": "Create a token for a user to authenticate future requests." + }, + "set_profile_picture": { + "name": "Set Profile Picture", + "description": "Set the profile picture for a user.", + "public": true + }, + "prepare_profile_picture": { + "name": "Start Profile Picture Upload", + "description": "Allow the user to begin uploading a profile picture.", + "public": true } }, "errors": { @@ -39,6 +51,14 @@ }, "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" } } } \ No newline at end of file diff --git a/modules/users/scripts/create_user.ts b/modules/users/scripts/create_user.ts index 59ef9e12..a30f14ee 100644 --- a/modules/users/scripts/create_user.ts +++ b/modules/users/scripts/create_user.ts @@ -6,7 +6,7 @@ export interface Request { } export interface Response { - user: User; + user: Omit; } export async function run( @@ -20,10 +20,16 @@ export async function run( data: { username: req.username ?? generateUsername(), }, + select: { + id: true, + username: true, + createdAt: true, + updatedAt: true, + }, }); return { - user, + user: user, }; } diff --git a/modules/users/scripts/get_user.ts b/modules/users/scripts/get_user.ts index 968b3ebb..df6f758f 100644 --- a/modules/users/scripts/get_user.ts +++ b/modules/users/scripts/get_user.ts @@ -1,5 +1,6 @@ import { ScriptContext } from "../module.gen.ts"; import { User } from "../utils/types.ts"; +import { withPfpUrls } from "../utils/pfp.ts"; export interface Request { userIds: string[]; @@ -20,5 +21,8 @@ export async function run( orderBy: { username: "desc" }, }); - return { users }; + + const usersWithPfps = await withPfpUrls(ctx, users); + + return { users: usersWithPfps }; } 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..57c18530 --- /dev/null +++ b/modules/users/utils/pfp.ts @@ -0,0 +1,34 @@ +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 }; + +export async function withPfpUrls( + ctx: T, + users: UserWithUploadidInfo[], +): Promise { + const fileRefs = users + .filter(user => user.avatarUploadId) + .map(user => ({ uploadId: user.avatarUploadId!, path: "profile-picture" })); + + 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; +} diff --git a/modules/users/utils/types.ts b/modules/users/utils/types.ts index 78c900cb..583ed283 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; + profilePictureUrl: string | null; } diff --git a/tests/basic/backend.json b/tests/basic/backend.json index 897a398e..463f11ab 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -20,7 +20,10 @@ "registry": "local" }, "users": { - "registry": "local" + "registry": "local", + "config": { + "maxProfilePictureBytes": 1048576 + } }, "uploads": { "registry": "local",