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 committed Apr 26, 2024
1 parent b93e72e commit 0c28066
Show file tree
Hide file tree
Showing 15 changed files with 282 additions and 67 deletions.
4 changes: 2 additions & 2 deletions modules/uploads/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export function getS3EnvConfig(s3Cfg: Config["s3"]): S3Config | null {
S3_AWS_SECRET_ACCESS_KEY,
} = Deno.env.toObject() as Partial<S3EnvConfig>;

const accessKeyId = S3_AWS_ACCESS_KEY_ID ?? s3Cfg.accessKeyId;
const secretAccessKey = S3_AWS_SECRET_ACCESS_KEY ?? s3Cfg.secretAccessKey;
const accessKeyId = s3Cfg.accessKeyId || S3_AWS_ACCESS_KEY_ID;
const secretAccessKey = s3Cfg.secretAccessKey || S3_AWS_SECRET_ACCESS_KEY;

if (
!accessKeyId ||
Expand Down
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",
];
35 changes: 0 additions & 35 deletions modules/users/db/migrations/20240307013613_init/migration.sql

This file was deleted.

23 changes: 0 additions & 23 deletions modules/users/db/migrations/20240312043558_init/migration.sql

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "User" (
"id" UUID NOT NULL,
"username" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"avatarUploadId" UUID,

CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
10 changes: 6 additions & 4 deletions modules/users/db/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ datasource db {
}

model User {
id String @id @default(uuid()) @db.Uuid
username String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid()) @db.Uuid
username String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
avatarUploadId String? @db.Uuid
}
16 changes: 16 additions & 0 deletions modules/users/module.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ tags:
authors:
- rivet-gg
- NathanFlurry
- Skyler Calaman
status: stable
dependencies:
rate_limit: {}
tokens: {}
uploads: {}
scripts:
get_user:
name: Get User
Expand All @@ -24,8 +26,22 @@ scripts:
create_user_token:
name: Create User Token
description: Create a token for a user to authenticate future requests.
set_profile_picture:
name: Set Profile Picture
description: Set the profile picture for a user.
public: true
prepare_profile_picture_upload:
name: Start Profile Picture Upload
description: Allow the user to begin uploading a profile picture.
public: true
errors:
token_not_user_token:
name: Token Not User Token
unknown_identity_type:
name: Unknown Identity Type
invalid_mime_type:
name: Invalid MIME Type
description: The MIME type for the supposed PFP isn't an image
file_too_large:
name: File Too Large
description: The file is larger than the configured maximum size for a profile picture
10 changes: 8 additions & 2 deletions modules/users/scripts/create_user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface Request {
}

export interface Response {
user: User;
user: Omit<User, "profilePictureUrl">;
}

export async function run(
Expand All @@ -20,10 +20,16 @@ export async function run(
data: {
username: req.username ?? generateUsername(),
},
select: {
id: true,
username: true,
createdAt: true,
updatedAt: true,
},
});

return {
user,
user: user,
};
}

Expand Down
6 changes: 5 additions & 1 deletion modules/users/scripts/get_user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ScriptContext } from "../_gen/scripts/get_user.ts";
import { User } from "../utils/types.ts";
import { withPfpUrls } from "../utils/pfp.ts";

export interface Request {
userIds: string[];
Expand All @@ -20,5 +21,8 @@ export async function run(
orderBy: { username: "desc" },
});

return { users };

const usersWithPfps = await withPfpUrls(ctx, users);

return { users: usersWithPfps };
}
57 changes: 57 additions & 0 deletions modules/users/scripts/prepare_profile_picture_upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ScriptContext, RuntimeError } from "../_gen/scripts/prepare_profile_picture_upload.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,
}
}

75 changes: 75 additions & 0 deletions modules/users/scripts/set_profile_picture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ScriptContext, RuntimeError } from "../_gen/scripts/set_profile_picture.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 "../_gen/test.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.prepareProfilePictureUpload({
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);
});
Loading

0 comments on commit 0c28066

Please sign in to comment.