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 1, 2024
1 parent 1d39eaf commit 52b0244
Show file tree
Hide file tree
Showing 13 changed files with 295 additions and 6 deletions.
3 changes: 3 additions & 0 deletions modules/users/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Config {
maxPfpBytes: number;
}
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";
24 changes: 20 additions & 4 deletions modules/users/db/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,24 @@ 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
pfpId String? @db.Uuid
pfp Pfp?
}

model Pfp {
uploadId String @id @db.Uuid
url String
urlExpiry DateTime
userId String @db.Uuid @unique
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
finishedAt DateTime?
}
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_pfp:
name: Set Profile Picture
description: Set the profile picture for a user.
public: true
start_pfp_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),
};
}

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),
));

return { users: usersWithPfps };
}
71 changes: 71 additions & 0 deletions modules/users/scripts/set_pfp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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 oldPfp = await db.pfp.findFirst({
where: { userId },
select: { uploadId: true },
});
if (oldPfp) {
await ctx.modules.uploads.delete({ uploadId: oldPfp.uploadId });
await db.pfp.delete({ where: { userId } });
}

// Assign the new profile picture to the user
await db.pfp.create({
data: {
userId,
uploadId: req.uploadId,
url: "",
urlExpiry: new Date(0).toISOString(),
finishedAt: new Date().toISOString(),
},
});

// Get the new user object
const user = await db.user.findFirst({
where: { id: userId },
select: {
id: true,
username: true,
createdAt: true,
updatedAt: true,
},
});

if (!user) {
throw new RuntimeError("internal_error", { cause: "User not found" });
}

return await withPfpUrl(
ctx,
user,
);
});

return { user };
}

50 changes: 50 additions & 0 deletions modules/users/scripts/start_pfp_upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ScriptContext, RuntimeError } from "../_gen/scripts/start_pfp_upload.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
if (!req.mime.startsWith("image/")) {
throw new RuntimeError(
"invalid_mime_type",
{ cause: `MIME type ${req.mime} is not an image` },
);
}

// Ensure the file is within the maximum configured size for a PFP
if (BigInt(req.contentLength) > ctx.userConfig.maxPfpBytes) {
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: `pfp/${userId}`, contentLength: req.contentLength, mime: req.mime },
],
});

return {
url: presigned.files[0].presignedUrl,
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";
import { decodeBase64 } from "https://deno.land/[email protected]/encoding/base64.ts";

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=="
const testPfpArr = decodeBase64(testPfp);

test("e2e", async (ctx: TestContext) => {
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.startPfpUpload({
mime: "image/bmp",
contentLength: atob(testPfp).length.toString(),
userToken: token.token,
});

// Upload the profile picture
await fetch(url, {
method: "PUT",
body: testPfpArr,
});

// Set the profile picture
await ctx.modules.users.setPfp({
uploadId,
userToken: token.token,
});

// Get PFP from URL
const { users: [{ pfpUrl }] } = await ctx.modules.users.getUser({ userIds: [user.id] });
assertExists(pfpUrl);

// Get PFP from URL
const getPfpFromUrl = await fetch(pfpUrl);
const pfp = new Uint8Array(await getPfpFromUrl.arrayBuffer());
assertEquals(pfp, testPfpArr);
});
49 changes: 49 additions & 0 deletions modules/users/utils/pfp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ModuleContext } from "../_gen/mod.ts";
import { User } from "./types.ts";

const PRE_EXPIRY_BUFFER = 1000 * 60 * 60; // 1 hour
const EXPIRY_TIME = 1000 * 60 * 60 * 24 * 7; // 1 week

export const refreshPfpUrl = async (ctx: ModuleContext, userId: string) => {
const pfp = await ctx.db.pfp.findFirst({
where: {
userId,
},
});

if (!pfp) {
return null;
}

if (Date.now() + PRE_EXPIRY_BUFFER > pfp.urlExpiry.getTime()) {
const reqTime = Date.now();

const path = `pfp/${userId}`;
const { files: [{ url }] } = await ctx.modules.uploads.getFileLinks({
files: [{ uploadId: pfp.uploadId, path }],
validSecs: EXPIRY_TIME / 1000,
});

await ctx.db.pfp.update({
where: {
userId,
},
data: {
url,
urlExpiry: new Date(reqTime + EXPIRY_TIME),
},
});

return url;
} else {
return pfp.url;
}
};

export const withPfpUrl = async <T extends ModuleContext>(ctx: T, user: Omit<User, "pfpUrl">) => {
const url = await refreshPfpUrl(ctx, user.id);
return {
...user,
pfpUrl: 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;
pfpUrl: 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:
maxPfpBytes: 1048576 # 1 MiB
uploads:
registry: local
config:
Expand Down

0 comments on commit 52b0244

Please sign in to comment.