This repository has been archived by the owner on Sep 17, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
c436d15
commit c7ec1e4
Showing
5 changed files
with
227 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import { ScriptContext, RuntimeError } from "../module.gen.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, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
import { ScriptContext, RuntimeError } from "../module.gen.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 }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import { test, TestContext } from "../module.gen.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.prepareProfilePicture({ | ||
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); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import { ModuleContext } from "../module.gen.ts"; | ||
import { User } from "./types.ts"; | ||
|
||
const EXPIRY_SECS = 60 * 60 * 24; // 1 day | ||
|
||
type UserWithUploadidInfo = Omit<User, "profilePictureUrl"> & { avatarUploadId: string | null }; | ||
type FileRef = { uploadId: string; path: string }; | ||
|
||
function getFileRefs(users: UserWithUploadidInfo[]) { | ||
const pairs: FileRef[] = []; | ||
for (const { avatarUploadId: uploadId } of users) { | ||
if (uploadId) { | ||
pairs.push({ uploadId: uploadId, path: "profile-picture" }); | ||
} | ||
} | ||
return pairs; | ||
} | ||
|
||
export async function withPfpUrls<T extends ModuleContext>( | ||
ctx: T, | ||
users: UserWithUploadidInfo[], | ||
): Promise<User[]> { | ||
const fileRefs = getFileRefs(users); | ||
|
||
const { files } = await ctx.modules.uploads.getPublicFileUrls({ | ||
files: fileRefs, | ||
expirySeconds: EXPIRY_SECS, | ||
}); | ||
|
||
const map = new Map(files.map((file) => [file.uploadId, file.url])); | ||
|
||
const completeUsers: User[] = []; | ||
for (const user of users) { | ||
if (user.avatarUploadId && map.has(user.avatarUploadId)) { | ||
const profilePictureUrl = map.get(user.avatarUploadId)!; | ||
completeUsers.push({ ...user, profilePictureUrl }); | ||
} else { | ||
completeUsers.push({ ...user, profilePictureUrl: null }); | ||
} | ||
} | ||
|
||
return completeUsers; | ||
} |