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

Commit c86878b

Browse files
committed
feat(users): users profile pictures
1 parent f41cf90 commit c86878b

File tree

5 files changed

+227
-0
lines changed

5 files changed

+227
-0
lines changed

modules/users/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export interface Config {
2+
maxProfilePictureBytes: number;
3+
allowedMimes?: string[];
4+
}
5+
6+
export const DEFAULT_MIME_TYPES = [
7+
"image/jpeg",
8+
"image/png",
9+
];
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { ScriptContext, RuntimeError } from "../module.gen.ts";
2+
import { DEFAULT_MIME_TYPES } from "../config.ts";
3+
4+
export interface Request {
5+
mime: string;
6+
contentLength: string;
7+
userToken: string;
8+
}
9+
10+
export interface Response {
11+
url: string;
12+
uploadId: string;
13+
}
14+
15+
export async function run(
16+
ctx: ScriptContext,
17+
req: Request,
18+
): Promise<Response> {
19+
// Authenticate/rate limit because this is a public route
20+
await ctx.modules.rateLimit.throttlePublic({ period: 60, requests: 5 });
21+
const { userId } = await ctx.modules.users.authenticateUser({ userToken: req.userToken });
22+
23+
// Ensure at least the MIME type says it is an image
24+
const allowedMimes = ctx.userConfig.allowedMimes ?? DEFAULT_MIME_TYPES;
25+
if (!allowedMimes.includes(req.mime)) {
26+
throw new RuntimeError(
27+
"invalid_mime_type",
28+
{ cause: `MIME type ${req.mime} is not an allowed image type` },
29+
);
30+
}
31+
32+
// Ensure the file is within the maximum configured size for a PFP
33+
if (BigInt(req.contentLength) > ctx.userConfig.maxProfilePictureBytes) {
34+
throw new RuntimeError(
35+
"file_too_large",
36+
{ cause: `File is too large (${req.contentLength} bytes)` },
37+
);
38+
}
39+
40+
// Prepare the upload to get the presigned URL
41+
const { upload: presigned } = await ctx.modules.uploads.prepare({
42+
files: [
43+
{
44+
path: `profile-picture`,
45+
contentLength: req.contentLength,
46+
mime: req.mime,
47+
multipart: false,
48+
},
49+
],
50+
});
51+
52+
return {
53+
url: presigned.files[0].presignedChunks[0].url,
54+
uploadId: presigned.id,
55+
}
56+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { ScriptContext, RuntimeError } from "../module.gen.ts";
2+
import { User } from "../utils/types.ts";
3+
import { withPfpUrls } from "../utils/pfp.ts";
4+
5+
export interface Request {
6+
uploadId: string;
7+
userToken: string;
8+
}
9+
10+
export interface Response {
11+
user: User;
12+
}
13+
14+
export async function run(
15+
ctx: ScriptContext,
16+
req: Request,
17+
): Promise<Response> {
18+
// Authenticate/rate limit because this is a public route
19+
await ctx.modules.rateLimit.throttlePublic({ period: 60, requests: 5 });
20+
const { userId } = await ctx.modules.users.authenticateUser({ userToken: req.userToken });
21+
22+
// Complete the upload in the `uploads` module
23+
await ctx.modules.uploads.complete({ uploadId: req.uploadId });
24+
25+
// Delete the old uploaded profile picture and replace it with the new one
26+
const [user] = await ctx.db.$transaction(async (db) => {
27+
// If there is an existing profile picture, delete it
28+
const oldUser = await db.user.findFirst({
29+
where: { id: userId },
30+
});
31+
32+
// (This means that `users.authenticateUser` is broken!)
33+
if (!oldUser) {
34+
throw new RuntimeError(
35+
"internal_error",
36+
{
37+
meta: "Existing user not found",
38+
},
39+
);
40+
}
41+
42+
if (oldUser.avatarUploadId) {
43+
await ctx.modules.uploads.delete({ uploadId: oldUser.avatarUploadId });
44+
}
45+
46+
// Update the user upload ID
47+
const user = await db.user.update({
48+
where: {
49+
id: userId,
50+
},
51+
data: {
52+
avatarUploadId: req.uploadId,
53+
},
54+
select: {
55+
id: true,
56+
username: true,
57+
avatarUploadId: true,
58+
createdAt: true,
59+
updatedAt: true,
60+
},
61+
});
62+
63+
if (!user) {
64+
throw new RuntimeError("internal_error", { cause: "User not found" });
65+
}
66+
67+
return await withPfpUrls(
68+
ctx,
69+
[user],
70+
);
71+
});
72+
73+
return { user };
74+
}

modules/users/tests/pfp.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { test, TestContext } from "../module.gen.ts";
2+
import { faker } from "https://deno.land/x/[email protected]/mod.ts";
3+
import { assertEquals } from "https://deno.land/[email protected]/assert/assert_equals.ts";
4+
import { assertExists } from "https://deno.land/[email protected]/assert/assert_exists.ts";
5+
6+
test("e2e", async (ctx: TestContext) => {
7+
const imageReq = await fetch("https://picsum.photos/200/300");
8+
const imageData = new Uint8Array(await imageReq.arrayBuffer());
9+
10+
11+
const { user } = await ctx.modules.users.createUser({
12+
username: faker.internet.userName(),
13+
});
14+
15+
const { token } = await ctx.modules.users.createUserToken({
16+
userId: user.id,
17+
});
18+
19+
const { url, uploadId } = await ctx.modules.users.prepareProfilePicture({
20+
mime: imageReq.headers.get("Content-Type") ?? "image/jpeg",
21+
contentLength: imageData.length.toString(),
22+
userToken: token.token,
23+
});
24+
25+
// Upload the profile picture
26+
await fetch(url, {
27+
method: "PUT",
28+
body: imageData,
29+
});
30+
31+
// Set the profile picture
32+
await ctx.modules.users.setProfilePicture({
33+
uploadId,
34+
userToken: token.token,
35+
});
36+
37+
// Get PFP from URL
38+
const { users: [{ profilePictureUrl }] } = await ctx.modules.users.getUser({ userIds: [user.id] });
39+
assertExists(profilePictureUrl);
40+
41+
// Get PFP from URL
42+
const getPfpFromUrl = await fetch(profilePictureUrl);
43+
const pfp = new Uint8Array(await getPfpFromUrl.arrayBuffer());
44+
assertEquals(pfp, imageData);
45+
});

modules/users/utils/pfp.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { ModuleContext } from "../module.gen.ts";
2+
import { User } from "./types.ts";
3+
4+
const EXPIRY_SECS = 60 * 60 * 24; // 1 day
5+
6+
type UserWithUploadidInfo = Omit<User, "profilePictureUrl"> & { avatarUploadId: string | null };
7+
type FileRef = { uploadId: string; path: string };
8+
9+
function getFileRefs(users: UserWithUploadidInfo[]) {
10+
const pairs: FileRef[] = [];
11+
for (const { avatarUploadId: uploadId } of users) {
12+
if (uploadId) {
13+
pairs.push({ uploadId: uploadId, path: "profile-picture" });
14+
}
15+
}
16+
return pairs;
17+
}
18+
19+
export async function withPfpUrls<T extends ModuleContext>(
20+
ctx: T,
21+
users: UserWithUploadidInfo[],
22+
): Promise<User[]> {
23+
const fileRefs = getFileRefs(users);
24+
25+
const { files } = await ctx.modules.uploads.getPublicFileUrls({
26+
files: fileRefs,
27+
expirySeconds: EXPIRY_SECS,
28+
});
29+
30+
const map = new Map(files.map((file) => [file.uploadId, file.url]));
31+
32+
const completeUsers: User[] = [];
33+
for (const user of users) {
34+
if (user.avatarUploadId && map.has(user.avatarUploadId)) {
35+
const profilePictureUrl = map.get(user.avatarUploadId)!;
36+
completeUsers.push({ ...user, profilePictureUrl });
37+
} else {
38+
completeUsers.push({ ...user, profilePictureUrl: null });
39+
}
40+
}
41+
42+
return completeUsers;
43+
}

0 commit comments

Comments
 (0)