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

Commit 888d74b

Browse files
committed
feat(users): users profile pictures
1 parent bd5c746 commit 888d74b

File tree

13 files changed

+295
-6
lines changed

13 files changed

+295
-6
lines changed

modules/users/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface Config {
2+
maxPfpBytes: number;
3+
}
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_pfp:
29+
name: Set Profile Picture
30+
description: Set the profile picture for a user.
31+
public: true
32+
start_pfp_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
}

modules/users/scripts/set_pfp.ts

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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { ScriptContext, RuntimeError } from "../_gen/scripts/start_pfp_upload.ts";
2+
3+
export interface Request {
4+
mime: string;
5+
contentLength: string;
6+
userToken: string;
7+
}
8+
9+
export interface Response {
10+
url: string;
11+
uploadId: string;
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+
// Ensure at least the MIME type says it is an image
23+
if (!req.mime.startsWith("image/")) {
24+
throw new RuntimeError(
25+
"invalid_mime_type",
26+
{ cause: `MIME type ${req.mime} is not an image` },
27+
);
28+
}
29+
30+
// Ensure the file is within the maximum configured size for a PFP
31+
if (BigInt(req.contentLength) > ctx.userConfig.maxPfpBytes) {
32+
throw new RuntimeError(
33+
"file_too_large",
34+
{ cause: `File is too large (${req.contentLength} bytes)` },
35+
);
36+
}
37+
38+
// Prepare the upload to get the presigned URL
39+
const { upload: presigned } = await ctx.modules.uploads.prepare({
40+
files: [
41+
{ path: `pfp/${userId}`, contentLength: req.contentLength, mime: req.mime },
42+
],
43+
});
44+
45+
return {
46+
url: presigned.files[0].presignedUrl,
47+
uploadId: presigned.id,
48+
}
49+
}
50+

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+
import { decodeBase64 } from "https://deno.land/std@0.217.0/encoding/base64.ts";
6+
7+
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=="
8+
const testPfpArr = decodeBase64(testPfp);
9+
10+
test("e2e", async (ctx: TestContext) => {
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.startPfpUpload({
20+
mime: "image/bmp",
21+
contentLength: atob(testPfp).length.toString(),
22+
userToken: token.token,
23+
});
24+
25+
// Upload the profile picture
26+
await fetch(url, {
27+
method: "PUT",
28+
body: testPfpArr,
29+
});
30+
31+
// Set the profile picture
32+
await ctx.modules.users.setPfp({
33+
uploadId,
34+
userToken: token.token,
35+
});
36+
37+
// Get PFP from URL
38+
const { users: [{ pfpUrl }] } = await ctx.modules.users.getUser({ userIds: [user.id] });
39+
assertExists(pfpUrl);
40+
41+
// Get PFP from URL
42+
const getPfpFromUrl = await fetch(pfpUrl);
43+
const pfp = new Uint8Array(await getPfpFromUrl.arrayBuffer());
44+
assertEquals(pfp, testPfpArr);
45+
});

0 commit comments

Comments
 (0)