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

Commit 799c23c

Browse files
committed
feat(users): users profile pictures
1 parent 9366389 commit 799c23c

File tree

13 files changed

+310
-6
lines changed

13 files changed

+310
-6
lines changed

modules/users/config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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+
"image/gif",
10+
"image/webp",
11+
];
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- AlterTable
2+
ALTER TABLE "User" ADD COLUMN "pfpId" UUID;
3+
4+
-- CreateTable
5+
CREATE TABLE "Pfp" (
6+
"uploadId" UUID NOT NULL,
7+
"fileId" UUID NOT NULL,
8+
"url" TEXT NOT NULL,
9+
"urlExpiry" TIMESTAMP(3) NOT NULL,
10+
"userId" UUID NOT NULL,
11+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12+
"updatedAt" TIMESTAMP(3) NOT NULL,
13+
"finishedAt" TIMESTAMP(3),
14+
15+
CONSTRAINT "Pfp_pkey" PRIMARY KEY ("uploadId")
16+
);
17+
18+
-- CreateIndex
19+
CREATE UNIQUE INDEX "Pfp_userId_key" ON "Pfp"("userId");
20+
21+
-- AddForeignKey
22+
ALTER TABLE "Pfp" ADD CONSTRAINT "Pfp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the column `fileId` on the `Pfp` table. All the data in the column will be lost.
5+
6+
*/
7+
-- AlterTable
8+
ALTER TABLE "Pfp" DROP COLUMN "fileId";

modules/users/db/schema.prisma

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,24 @@ datasource db {
44
}
55

66
model User {
7-
id String @id @default(uuid()) @db.Uuid
8-
username String @unique
9-
createdAt DateTime @default(now())
10-
updatedAt DateTime @updatedAt
7+
id String @id @default(uuid()) @db.Uuid
8+
username String @unique
9+
createdAt DateTime @default(now())
10+
updatedAt DateTime @updatedAt
11+
12+
pfpId String? @db.Uuid
13+
pfp Pfp?
14+
}
15+
16+
model Pfp {
17+
uploadId String @id @db.Uuid
18+
url String
19+
urlExpiry DateTime
20+
21+
userId String @db.Uuid @unique
22+
user User @relation(fields: [userId], references: [id])
23+
24+
createdAt DateTime @default(now())
25+
updatedAt DateTime @updatedAt
26+
finishedAt DateTime?
1127
}

modules/users/module.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ status: stable
1111
dependencies:
1212
rate_limit: {}
1313
tokens: {}
14+
uploads: {}
1415
scripts:
1516
get_user:
1617
name: Get User
@@ -24,8 +25,22 @@ scripts:
2425
create_user_token:
2526
name: Create User Token
2627
description: Create a token for a user to authenticate future requests.
28+
set_profile_picture:
29+
name: Set Profile Picture
30+
description: Set the profile picture for a user.
31+
public: true
32+
start_profile_picture_upload:
33+
name: Start Profile Picture Upload
34+
description: Allow the user to begin uploading a profile picture.
35+
public: true
2736
errors:
2837
token_not_user_token:
2938
name: Token Not User Token
3039
unknown_identity_type:
3140
name: Unknown Identity Type
41+
invalid_mime_type:
42+
name: Invalid MIME Type
43+
description: The MIME type for the supposed PFP isn't an image
44+
file_too_large:
45+
name: File Too Large
46+
description: The file is larger than the configured maximum size for a profile picture

modules/users/scripts/create_user.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ScriptContext } from "../_gen/scripts/create_user.ts";
2+
import { withPfpUrl } from "../utils/pfp.ts";
23
import { User } from "../utils/types.ts";
34

45
export interface Request {
@@ -23,7 +24,7 @@ export async function run(
2324
});
2425

2526
return {
26-
user,
27+
user: await withPfpUrl(ctx, user),
2728
};
2829
}
2930

modules/users/scripts/get_user.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ScriptContext } from "../_gen/scripts/get_user.ts";
22
import { User } from "../utils/types.ts";
3+
import { withPfpUrl } from "../utils/pfp.ts";
34

45
export interface Request {
56
userIds: string[];
@@ -20,5 +21,10 @@ export async function run(
2021
orderBy: { username: "desc" },
2122
});
2223

23-
return { users };
24+
25+
const usersWithPfps = await Promise.all(users.map(
26+
user => withPfpUrl(ctx, user),
27+
));
28+
29+
return { users: usersWithPfps };
2430
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { ScriptContext, RuntimeError } from "../_gen/scripts/set_pfp.ts";
2+
import { User } from "../utils/types.ts";
3+
import { withPfpUrl } 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 oldPfp = await db.pfp.findFirst({
29+
where: { userId },
30+
select: { uploadId: true },
31+
});
32+
if (oldPfp) {
33+
await ctx.modules.uploads.delete({ uploadId: oldPfp.uploadId });
34+
await db.pfp.delete({ where: { userId } });
35+
}
36+
37+
// Assign the new profile picture to the user
38+
await db.pfp.create({
39+
data: {
40+
userId,
41+
uploadId: req.uploadId,
42+
url: "",
43+
urlExpiry: new Date(0).toISOString(),
44+
finishedAt: new Date().toISOString(),
45+
},
46+
});
47+
48+
// Get the new user object
49+
const user = await db.user.findFirst({
50+
where: { id: userId },
51+
select: {
52+
id: true,
53+
username: true,
54+
createdAt: true,
55+
updatedAt: true,
56+
},
57+
});
58+
59+
if (!user) {
60+
throw new RuntimeError("internal_error", { cause: "User not found" });
61+
}
62+
63+
return await withPfpUrl(
64+
ctx,
65+
user,
66+
);
67+
});
68+
69+
return { user };
70+
}
71+
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ScriptContext, RuntimeError } from "../_gen/scripts/start_pfp_upload.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].presignedUrls[0].url,
54+
uploadId: presigned.id,
55+
}
56+
}
57+

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 "../_gen/test.ts";
2+
import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts";
3+
import { assertEquals } from "https://deno.land/std@0.217.0/assert/assert_equals.ts";
4+
import { assertExists } from "https://deno.land/std@0.217.0/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.startProfilePictureUpload({
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+
});

0 commit comments

Comments
 (0)