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 18, 2024
1 parent 9366389 commit 530a5e9
Show file tree
Hide file tree
Showing 14 changed files with 293 additions and 6 deletions.
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",
];
22 changes: 22 additions & 0 deletions modules/users/db/migrations/20240401001004_add_pfps/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "pfpId" UUID;

-- CreateTable
CREATE TABLE "Pfp" (
"uploadId" UUID NOT NULL,
"fileId" UUID NOT NULL,
"url" TEXT NOT NULL,
"urlExpiry" TIMESTAMP(3) NOT NULL,
"userId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"finishedAt" TIMESTAMP(3),

CONSTRAINT "Pfp_pkey" PRIMARY KEY ("uploadId")
);

-- CreateIndex
CREATE UNIQUE INDEX "Pfp_userId_key" ON "Pfp"("userId");

-- AddForeignKey
ALTER TABLE "Pfp" ADD CONSTRAINT "Pfp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `fileId` on the `Pfp` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Pfp" DROP COLUMN "fileId";
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `pfpId` on the `User` table. All the data in the column will be lost.
- You are about to drop the `Pfp` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Pfp" DROP CONSTRAINT "Pfp_userId_fkey";

-- AlterTable
ALTER TABLE "User" DROP COLUMN "pfpId",
ADD COLUMN "avatarUploadId" UUID;

-- DropTable
DROP TABLE "Pfp";
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
}
15 changes: 15 additions & 0 deletions modules/users/module.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ status: stable
dependencies:
rate_limit: {}
tokens: {}
uploads: {}
scripts:
get_user:
name: Get User
Expand All @@ -24,8 +25,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
start_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
3 changes: 2 additions & 1 deletion modules/users/scripts/create_user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ScriptContext } from "../_gen/scripts/create_user.ts";
import { withPfpUrl } from "../utils/pfp.ts";
import { User } from "../utils/types.ts";

export interface Request {
Expand All @@ -23,7 +24,7 @@ export async function run(
});

return {
user,
user: await withPfpUrl(ctx, user, user.avatarUploadId),
};
}

Expand Down
8 changes: 7 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 { withPfpUrl } from "../utils/pfp.ts";

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

return { users };

const usersWithPfps = await Promise.all(users.map(
user => withPfpUrl(ctx, user, user.avatarUploadId),
));

return { users: usersWithPfps };
}
76 changes: 76 additions & 0 deletions modules/users/scripts/set_profile_picture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ScriptContext, RuntimeError } from "../_gen/scripts/set_pfp.ts";
import { User } from "../utils/types.ts";
import { withPfpUrl } 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 withPfpUrl(
ctx,
user,
user.avatarUploadId,
);
});

return { user };
}

57 changes: 57 additions & 0 deletions modules/users/scripts/start_profile_picture_upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ScriptContext, RuntimeError } from "../_gen/scripts/start_pfp_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].presignedUrls[0].url,
uploadId: presigned.id,
}
}

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.startProfilePictureUpload({
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);
});
27 changes: 27 additions & 0 deletions modules/users/utils/pfp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ModuleContext } from "../_gen/mod.ts";
import { User } from "./types.ts";

const EXPIRY_SECS = 60 * 60 * 24; // 1 day

export const withPfpUrl = async <T extends ModuleContext>(
ctx: T,
user: Omit<User, "profilePictureUrl">,
uploadId: string | null,
) => {
if (!uploadId) {
return {
...user,
profilePictureUrl: null,
};
}

const { files: [{ url }] } = await ctx.modules.uploads.getPublicFileUrls({
files: [{ uploadId, path: "profile-picture" }],
validSecs: EXPIRY_SECS,
});

return {
...user,
profilePictureUrl: url,
};
}
1 change: 1 addition & 0 deletions modules/users/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export interface User {
username: string;
createdAt: Date;
updatedAt: Date;
profilePictureUrl: string | null;
}
2 changes: 2 additions & 0 deletions tests/basic/backend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ modules:
registry: local
users:
registry: local
config:
maxProfilePictureBytes: 1048576 # 1 MiB
uploads:
registry: local
config:
Expand Down

0 comments on commit 530a5e9

Please sign in to comment.