Skip to content
This repository has been archived by the owner on Sep 17, 2024. It is now read-only.

Commit

Permalink
feat(users): users profile pictures
Browse files Browse the repository at this point in the history
  • Loading branch information
Blckbrry-Pi authored and NathanFlurry committed Jun 10, 2024
1 parent c436d15 commit c7ec1e4
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 0 deletions.
9 changes: 9 additions & 0 deletions modules/users/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface Config {
maxProfilePictureBytes: number;
allowedMimes?: string[];
}

export const DEFAULT_MIME_TYPES = [
"image/jpeg",
"image/png",
];
56 changes: 56 additions & 0 deletions modules/users/scripts/prepare_profile_picture.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
// 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,
}
}
74 changes: 74 additions & 0 deletions modules/users/scripts/set_profile_picture.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
// 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 };
}
45 changes: 45 additions & 0 deletions modules/users/tests/pfp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, TestContext } from "../module.gen.ts";
import { faker } from "https://deno.land/x/[email protected]/mod.ts";
import { assertEquals } from "https://deno.land/[email protected]/assert/assert_equals.ts";
import { assertExists } from "https://deno.land/[email protected]/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);
});
43 changes: 43 additions & 0 deletions modules/users/utils/pfp.ts
Original file line number Diff line number Diff line change
@@ -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<User, "profilePictureUrl"> & { 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<T extends ModuleContext>(
ctx: T,
users: UserWithUploadidInfo[],
): Promise<User[]> {
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;
}

0 comments on commit c7ec1e4

Please sign in to comment.