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

Commit 530a5e9

Browse files
committed
feat(users): users profile pictures
1 parent 9366389 commit 530a5e9

File tree

14 files changed

+293
-6
lines changed

14 files changed

+293
-6
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: 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";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the column `pfpId` on the `User` table. All the data in the column will be lost.
5+
- You are about to drop the `Pfp` table. If the table is not empty, all the data it contains will be lost.
6+
7+
*/
8+
-- DropForeignKey
9+
ALTER TABLE "Pfp" DROP CONSTRAINT "Pfp_userId_fkey";
10+
11+
-- AlterTable
12+
ALTER TABLE "User" DROP COLUMN "pfpId",
13+
ADD COLUMN "avatarUploadId" UUID;
14+
15+
-- DropTable
16+
DROP TABLE "Pfp";

modules/users/db/schema.prisma

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ 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+
avatarUploadId String? @db.Uuid
1113
}

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, user.avatarUploadId),
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, user.avatarUploadId),
27+
));
28+
29+
return { users: usersWithPfps };
2430
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 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 withPfpUrl(
68+
ctx,
69+
user,
70+
user.avatarUploadId,
71+
);
72+
});
73+
74+
return { user };
75+
}
76+
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+

0 commit comments

Comments
 (0)