diff --git a/apps/backend/prisma/migrations/20250730164439_profile_image_key/migration.sql b/apps/backend/prisma/migrations/20250730164439_profile_image_key/migration.sql index c498bbc84..c5d377254 100644 --- a/apps/backend/prisma/migrations/20250730164439_profile_image_key/migration.sql +++ b/apps/backend/prisma/migrations/20250730164439_profile_image_key/migration.sql @@ -3,3 +3,6 @@ ALTER TABLE "ProjectUser" ADD COLUMN "profileImageKey" TEXT; -- AlterTable ALTER TABLE "Team" ADD COLUMN "profileImageKey" TEXT; + +-- AlterTable +ALTER TABLE "TeamMember" ADD COLUMN "profileImageKey" TEXT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 8fd10344e..5d8391fce 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -105,8 +105,13 @@ model TeamMember { teamId String @db.Uuid // This will override the displayName of the user in this team. - displayName String? + displayName String? + // This will override the profileImageUrl of the user in this team. + // if profileImageKey is set, we fetch the image from S3 + // otherwise if the profileImageUrl is set, we send the user to the URL. + // if neither is set, we return null. + profileImageKey String? profileImageUrl String? createdAt DateTime @default(now()) diff --git a/apps/backend/src/app/api/latest/team-member-profiles/crud.tsx b/apps/backend/src/app/api/latest/team-member-profiles/crud.tsx index a58e1d1d3..96d6eeed4 100644 --- a/apps/backend/src/app/api/latest/team-member-profiles/crud.tsx +++ b/apps/backend/src/app/api/latest/team-member-profiles/crud.tsx @@ -1,6 +1,7 @@ import { ensureTeamExists, ensureTeamMembershipExists, ensureUserExists, ensureUserTeamPermissionExists } from "@/lib/request-checks"; import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { uploadAndGetImageUpdateInfo } from "@/s3"; import { Prisma } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; import { teamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/crud/team-member-profiles"; @@ -149,7 +150,7 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa }, data: { displayName: data.display_name, - profileImageUrl: data.profile_image_url, + ...await uploadAndGetImageUpdateInfo(data.profile_image_url, "team-member-profile-images") }, include: fullInclude, }); diff --git a/apps/backend/src/app/api/latest/teams/crud.tsx b/apps/backend/src/app/api/latest/teams/crud.tsx index d1d26a3b3..8fd4744fd 100644 --- a/apps/backend/src/app/api/latest/teams/crud.tsx +++ b/apps/backend/src/app/api/latest/teams/crud.tsx @@ -2,6 +2,7 @@ import { ensureTeamExists, ensureTeamMembershipExists, ensureUserExists, ensureU import { sendTeamCreatedWebhook, sendTeamDeletedWebhook, sendTeamUpdatedWebhook } from "@/lib/webhooks"; import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { uploadAndGetImageUpdateInfo } from "@/s3"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { Prisma } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -17,7 +18,7 @@ export function teamPrismaToCrud(prisma: Prisma.TeamGetPayload<{}>) { return { id: prisma.teamId, display_name: prisma.displayName, - profile_image_url: prisma.profileImageUrl, + profile_image_url: prisma.profileImageKey ? getS3PublicUrl(prisma.profileImageKey) : prisma.profileImageUrl, created_at_millis: prisma.createdAt.getTime(), client_metadata: prisma.clientMetadata, client_read_only_metadata: prisma.clientReadOnlyMetadata, @@ -77,10 +78,10 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC mirroredProjectId: auth.project.id, mirroredBranchId: auth.branchId, tenancyId: auth.tenancy.id, - profileImageUrl: data.profile_image_url, clientMetadata: data.client_metadata === null ? Prisma.JsonNull : data.client_metadata, clientReadOnlyMetadata: data.client_read_only_metadata === null ? Prisma.JsonNull : data.client_read_only_metadata, serverMetadata: data.server_metadata === null ? Prisma.JsonNull : data.server_metadata, + ...await uploadAndGetImageUpdateInfo(data.profile_image_url, "team-profile-images") }, }); @@ -161,10 +162,10 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC }, data: { displayName: data.display_name, - profileImageUrl: data.profile_image_url, clientMetadata: data.client_metadata === null ? Prisma.JsonNull : data.client_metadata, clientReadOnlyMetadata: data.client_read_only_metadata === null ? Prisma.JsonNull : data.client_read_only_metadata, serverMetadata: data.server_metadata === null ? Prisma.JsonNull : data.server_metadata, + ...await uploadAndGetImageUpdateInfo(data.profile_image_url, "team-profile-images") }, }); }); diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 71d4c7baf..eff3d5ac7 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -7,7 +7,7 @@ import { PrismaTransaction } from "@/lib/types"; import { sendTeamMembershipDeletedWebhook, sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks"; import { RawQuery, getPrismaClientForSourceOfTruth, getPrismaClientForTenancy, getPrismaSchemaForSourceOfTruth, getPrismaSchemaForTenancy, globalPrismaClient, rawQuery, retryTransaction, sqlQuoteIdent } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; -import { checkImageString, getS3PublicUrl, uploadBase64Image } from "@/s3"; +import { getS3PublicUrl, uploadAndGetImageUpdateInfo } from "@/s3"; import { log } from "@/utils/telemetry"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { BooleanTrue, Prisma } from "@prisma/client"; @@ -324,7 +324,7 @@ export function getUserQuery(projectId: string, branchId: string, userId: string selected_team: row.SelectedTeamMember ? { id: row.SelectedTeamMember.Team.teamId, display_name: row.SelectedTeamMember.Team.displayName, - profile_image_url: row.SelectedTeamMember.Team.profileImageUrl, + profile_image_url: row.SelectedTeamMember.Team.profileImageKey ? getS3PublicUrl(row.SelectedTeamMember.Team.profileImageKey) : row.SelectedTeamMember.Team.profileImageUrl, created_at_millis: new Date(row.SelectedTeamMember.Team.createdAt + "Z").getTime(), client_metadata: row.SelectedTeamMember.Team.clientMetadata, client_read_only_metadata: row.SelectedTeamMember.Team.clientReadOnlyMetadata, @@ -347,37 +347,6 @@ export function getUserIfOnGlobalPrismaClientQuery(projectId: string, branchId: }; } -async function getProfileImageUrl(input: string | null | undefined) { - let profileImageKey: string | null | undefined = undefined; - let profileImageUrl: string | null | undefined = undefined; - if (input) { - const checkResult = checkImageString(input); - if (checkResult.isBase64Image) { - const { key } = await uploadBase64Image({ input, folderName: "profile-images" }); - profileImageKey = key; - } else if (checkResult.isUrl) { - profileImageUrl = input; - } else { - throw new StatusError(StatusError.BadRequest, "Invalid profile image URL"); - } - - return { - profileImageKey, - profileImageUrl, - }; - } else if (input === null) { - return { - profileImageKey: null, - profileImageUrl: null, - }; - } else { - return { - profileImageKey: undefined, - profileImageUrl: undefined, - }; - } -} - export async function getUser(options: { userId: string } & ({ projectId: string, branchId: string } | { tenancyId: string })) { let projectId, branchId; if (!("tenancyId" in options)) { @@ -520,7 +489,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC serverMetadata: data.server_metadata === null ? Prisma.JsonNull : data.server_metadata, totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), isAnonymous: data.is_anonymous ?? false, - ...await getProfileImageUrl(data.profile_image_url) + ...await uploadAndGetImageUpdateInfo(data.profile_image_url, "user-profile-images") }, include: userFullInclude, }); @@ -976,7 +945,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC requiresTotpMfa: data.totp_secret_base64 === undefined ? undefined : (data.totp_secret_base64 !== null), totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), isAnonymous: data.is_anonymous ?? undefined, - ...await getProfileImageUrl(data.profile_image_url) + ...await uploadAndGetImageUpdateInfo(data.profile_image_url, "user-profile-images") }, include: userFullInclude, }); diff --git a/apps/backend/src/s3.tsx b/apps/backend/src/s3.tsx index 133d81912..79656da97 100644 --- a/apps/backend/src/s3.tsx +++ b/apps/backend/src/s3.tsx @@ -1,5 +1,6 @@ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { parseBase64Image } from "./lib/images"; export const s3 = new S3Client({ @@ -52,3 +53,37 @@ export function checkImageString(input: string) { isUrl: /^https?:\/\//.test(input), }; } + +export async function uploadAndGetImageUpdateInfo( + input: string | null | undefined, + folderName: 'user-profile-images' | 'team-profile-images' | 'team-member-profile-images' +) { + let profileImageKey: string | null | undefined = undefined; + let profileImageUrl: string | null | undefined = undefined; + if (input) { + const checkResult = checkImageString(input); + if (checkResult.isBase64Image) { + const { key } = await uploadBase64Image({ input, folderName }); + profileImageKey = key; + } else if (checkResult.isUrl) { + profileImageUrl = input; + } else { + throw new StatusError(StatusError.BadRequest, "Invalid profile image URL"); + } + + return { + profileImageKey, + profileImageUrl, + }; + } else if (input === null) { + return { + profileImageKey: null, + profileImageUrl: null, + }; + } else { + return { + profileImageKey: undefined, + profileImageUrl: undefined, + }; + } +}