diff --git a/apps/backend/.env b/apps/backend/.env index a485cf467d..282ee9da26 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -50,6 +50,14 @@ STACK_DIRECT_DATABASE_CONNECTION_STRING=# enter your direct (unpooled or session STACK_SVIX_SERVER_URL=# For prod, leave it empty. For local development, use `http://localhost:8113` STACK_SVIX_API_KEY=# enter the API key for the Svix webhook service here. Use `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2MzksImV4cCI6MTk3MDUwMDYzOSwibmJmIjoxNjU1MTQwNjM5LCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.En8w77ZJWbd0qrMlHHupHUB-4cx17RfzFykseg95SUk` for local development +# S3 +STACK_S3_ENDPOINT=# S3 endpoint URL (e.g., 'https://s3.amazonaws.com' for AWS or custom endpoint for S3-compatible services) +STACK_S3_REGION= +STACK_S3_ACCESS_KEY_ID= +STACK_S3_SECRET_ACCESS_KEY= +STACK_S3_BUCKET= + + # Misc, optional STACK_ACCESS_TOKEN_EXPIRATION_TIME=# enter the expiration time for the access token here. Optional, don't specify it for default value STACK_SETUP_ADMIN_GITHUB_ID=# enter the account ID of the admin user here, and after running the seed script they will be able to access the internal project in the Stack dashboard. Optional, don't specify it for default value diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 5e572f5669..bceceab5e4 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -47,3 +47,10 @@ STACK_INTEGRATION_CLIENTS_CONFIG=[{"client_id": "neon-local", "client_secret": " CRON_SECRET=mock_cron_secret STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key STACK_OPENAI_API_KEY=mock_openai_api_key + +# S3 Configuration for local development using s3mock +STACK_S3_ENDPOINT=http://localhost:8121 +STACK_S3_REGION=us-east-1 +STACK_S3_ACCESS_KEY_ID=s3mockroot +STACK_S3_SECRET_ACCESS_KEY=s3mockroot +STACK_S3_BUCKET=stack-storage diff --git a/apps/backend/package.json b/apps/backend/package.json index 7ee342cc59..d4bf9e014a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@ai-sdk/openai": "^1.3.23", + "@aws-sdk/client-s3": "^3.855.0", "@next/bundle-analyzer": "15.2.3", "@node-oauth/oauth2-server": "^5.1.0", "@opentelemetry/api": "^1.9.0", @@ -77,8 +78,8 @@ "nodemailer": "^6.9.10", "oidc-provider": "^8.5.1", "openid-client": "5.6.4", - "postgres": "^3.4.5", "pg": "^8.16.3", + "postgres": "^3.4.5", "posthog-node": "^4.1.0", "react": "19.0.0", "react-dom": "19.0.0", diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 9b04c84007..724127c956 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -75,10 +75,10 @@ model Team { updatedAt DateTime @updatedAt displayName String - profileImageUrl String? clientMetadata Json? clientReadOnlyMetadata Json? serverMetadata Json? + profileImageUrl String? teamMembers TeamMember[] projectApiKey ProjectApiKey[] @@ -101,7 +101,6 @@ model TeamMember { // This will override the displayName of the user in this team. displayName String? - // This will override the profileImageUrl of the user in this team. profileImageUrl String? createdAt DateTime @default(now()) @@ -157,11 +156,11 @@ model ProjectUser { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - profileImageUrl String? displayName String? serverMetadata Json? clientReadOnlyMetadata Json? clientMetadata Json? + profileImageUrl String? requiresTotpMfa Boolean @default(false) totpSecret Bytes? isAnonymous Boolean @default(false) 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 a58e1d1d31..a252bca020 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 { uploadAndGetUrl } 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, + profileImageUrl: await uploadAndGetUrl(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 7a662c732c..c52ffc06c7 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 { uploadAndGetUrl } from "@/s3"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { Prisma } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -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, + profileImageUrl: await uploadAndGetUrl(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, + profileImageUrl: await uploadAndGetUrl(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 fd91b50fe7..f93a52c1cc 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -7,6 +7,8 @@ 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 { uploadAndGetUrl } from "@/s3"; +import { log } from "@/utils/telemetry"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { BooleanTrue, Prisma } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -19,7 +21,6 @@ import { StackAssertionError, StatusError, captureError, throwErr } from "@stack import { hashPassword, isPasswordHashValid } from "@stackframe/stack-shared/dist/utils/hashes"; import { has } from "@stackframe/stack-shared/dist/utils/objects"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; -import { log } from "@stackframe/stack-shared/dist/utils/telemetry"; import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { teamPrismaToCrud, teamsCrudHandlers } from "../teams/crud"; @@ -476,6 +477,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC const config = auth.tenancy.config; + const newUser = await tx.projectUser.create({ data: { tenancyId: auth.tenancy.id, @@ -485,9 +487,9 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC 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, - profileImageUrl: data.profile_image_url, totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), isAnonymous: data.is_anonymous ?? false, + profileImageUrl: await uploadAndGetUrl(data.profile_image_url, "user-profile-images") }, include: userFullInclude, }); @@ -940,10 +942,10 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC 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, - profileImageUrl: data.profile_image_url, 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, + profileImageUrl: await uploadAndGetUrl(data.profile_image_url, "user-profile-images") }, include: userFullInclude, }); diff --git a/apps/backend/src/auto-migrations/auto-migration.tests.ts b/apps/backend/src/auto-migrations/auto-migration.tests.ts index fa7f679219..4069101a3f 100644 --- a/apps/backend/src/auto-migrations/auto-migration.tests.ts +++ b/apps/backend/src/auto-migrations/auto-migration.tests.ts @@ -201,8 +201,8 @@ import.meta.vitest?.test("applies migrations concurrently", runTest(async ({ exp const l1 = result1.newlyAppliedMigrationNames.length; const l2 = result2.newlyAppliedMigrationNames.length; - // One of the two migrations should be applied, but not both - expect((l1 === 2 && l2 === 0) || (l1 === 0 && l2 === 2)).toBe(true); + // the sum of the two should be 2 + expect(l1 + l2).toBe(2); await prismaClient.$executeRaw`INSERT INTO test (name) VALUES ('test_value')`; const result = await prismaClient.$queryRaw`SELECT name FROM test` as { name: string }[]; @@ -222,11 +222,8 @@ import.meta.vitest?.test("applies migrations concurrently with 20 concurrent mig const appliedCounts = results.map(result => result.newlyAppliedMigrationNames.length); // Only one of the promises should have applied all migrations, the rest should have applied none - const successfulApplies = appliedCounts.filter(count => count === 2); - const emptyApplies = appliedCounts.filter(count => count === 0); - - expect(successfulApplies.length).toBe(1); - expect(emptyApplies.length).toBe(19); + const successfulCounts = appliedCounts.reduce((sum, count) => sum + count, 0); + expect(successfulCounts).toBe(2); await prismaClient.$executeRaw`INSERT INTO test (name) VALUES ('test_value')`; const result = await prismaClient.$queryRaw`SELECT name FROM test` as { name: string }[]; diff --git a/apps/backend/src/lib/images.tsx b/apps/backend/src/lib/images.tsx new file mode 100644 index 0000000000..ce43ca2f70 --- /dev/null +++ b/apps/backend/src/lib/images.tsx @@ -0,0 +1,92 @@ +export class ImageProcessingError extends Error { + constructor(message: string) { + super(message); + this.name = 'ImageProcessingError'; + } +} + +export async function parseBase64Image(input: string, options: { + maxBytes?: number, + maxWidth?: number, + maxHeight?: number, + allowTypes?: string[], +} = { + maxBytes: 1024 * 300, + maxWidth: 4096, + maxHeight: 4096, + allowTypes: ['image/jpeg', 'image/png', 'image/webp'], +}) { + // Remove data URL prefix if present (e.g., "data:image/jpeg;base64,") + const base64Data = input.replace(/^data:image\/[a-zA-Z0-9]+;base64,/, ''); + + // check the size before and after the base64 conversion + if (base64Data.length > options.maxBytes!) { + throw new ImageProcessingError(`Image size (${base64Data.length} bytes) exceeds maximum allowed size (${options.maxBytes} bytes)`); + } + + // Convert base64 to buffer + let imageBuffer: Buffer; + try { + imageBuffer = Buffer.from(base64Data, 'base64'); + } catch (error) { + throw new ImageProcessingError('Invalid base64 image data'); + } + + // Check file size + if (options.maxBytes && imageBuffer.length > options.maxBytes) { + throw new ImageProcessingError(`Image size (${imageBuffer.length} bytes) exceeds maximum allowed size (${options.maxBytes} bytes)`); + } + + // Dynamically import sharp + const sharp = (await import('sharp')).default; + + // Use Sharp to load image and get metadata + let sharpImage: any; + let metadata: any; + + try { + sharpImage = sharp(imageBuffer); + metadata = await sharpImage.metadata(); + } catch (error) { + throw new ImageProcessingError('Invalid image format or corrupted image data'); + } + + // Validate image format + if (!metadata.format) { + throw new ImageProcessingError('Unable to determine image format'); + } + + const mimeType = `image/${metadata.format}`; + if (options.allowTypes && !options.allowTypes.includes(mimeType)) { + throw new ImageProcessingError(`Image type ${mimeType} is not allowed. Allowed types: ${options.allowTypes.join(', ')}`); + } + + if (!metadata.width || !metadata.height) { + throw new ImageProcessingError('Unable to determine image dimensions'); + } + + if (options.maxWidth && metadata.width > options.maxWidth) { + throw new ImageProcessingError(`Image width (${metadata.width}px) exceeds maximum allowed width (${options.maxWidth}px)`); + } + + if (options.maxHeight && metadata.height > options.maxHeight) { + throw new ImageProcessingError(`Image height (${metadata.height}px) exceeds maximum allowed height (${options.maxHeight}px)`); + } + + // Return the validated image data and metadata + return { + buffer: imageBuffer, + metadata: { + format: metadata.format, + mimeType, + width: metadata.width, + height: metadata.height, + size: imageBuffer.length, + channels: metadata.channels, + density: metadata.density, + hasProfile: metadata.hasProfile, + hasAlpha: metadata.hasAlpha, + }, + sharp: sharpImage, + }; +} diff --git a/apps/backend/src/s3.tsx b/apps/backend/src/s3.tsx new file mode 100644 index 0000000000..adbc5d10c7 --- /dev/null +++ b/apps/backend/src/s3.tsx @@ -0,0 +1,101 @@ +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { ImageProcessingError, parseBase64Image } from "./lib/images"; + +const S3_REGION = getEnvVariable("STACK_S3_REGION", ""); +const S3_ENDPOINT = getEnvVariable("STACK_S3_ENDPOINT", ""); +const S3_BUCKET = getEnvVariable("STACK_S3_BUCKET", ""); +const S3_ACCESS_KEY_ID = getEnvVariable("STACK_S3_ACCESS_KEY_ID", ""); +const S3_SECRET_ACCESS_KEY = getEnvVariable("STACK_S3_SECRET_ACCESS_KEY", ""); + +const HAS_S3 = !!S3_REGION && !!S3_ENDPOINT && !!S3_BUCKET && !!S3_ACCESS_KEY_ID && !!S3_SECRET_ACCESS_KEY; + +if (!HAS_S3) { + console.warn("S3 bucket is not configured. File upload features will not be available."); +} + +const s3Client = HAS_S3 ? new S3Client({ + region: S3_REGION, + endpoint: S3_ENDPOINT, + forcePathStyle: true, + credentials: { + accessKeyId: S3_ACCESS_KEY_ID, + secretAccessKey: S3_SECRET_ACCESS_KEY, + }, +}) : undefined; + +export function getS3PublicUrl(key: string): string { + return `${S3_ENDPOINT}/${S3_BUCKET}/${key}`; +} + +async function uploadBase64Image({ + input, + maxBytes = 1024 * 300, + folderName, +}: { + input: string, + maxBytes?: number, + folderName: string, +}) { + if (!s3Client) { + throw new StackAssertionError("S3 is not configured"); + } + + let buffer: Buffer; + let format: string; + try { + const result = await parseBase64Image(input, { maxBytes }); + buffer = result.buffer; + format = result.metadata.format; + } catch (error) { + if (error instanceof ImageProcessingError) { + throw new StatusError(StatusError.BadRequest, error.message); + } + throw error; + } + + const key = `${folderName}/${crypto.randomUUID()}.${format}`; + + const command = new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + Body: buffer, + }); + + await s3Client.send(command); + + return { + key, + url: getS3PublicUrl(key), + }; +} + +export function checkImageString(input: string) { + return { + isBase64Image: /^data:image\/[a-zA-Z0-9]+;base64,/.test(input), + isUrl: /^https?:\/\//.test(input), + }; +} + +export async function uploadAndGetUrl( + input: string | null | undefined, + folderName: 'user-profile-images' | 'team-profile-images' | 'team-member-profile-images' +) { + if (input) { + const checkResult = checkImageString(input); + if (checkResult.isBase64Image) { + const { url } = await uploadBase64Image({ input, folderName }); + return url; + } else if (checkResult.isUrl) { + return input; + } else { + throw new StatusError(StatusError.BadRequest, "Invalid profile image URL"); + } + + } else if (input === null) { + return null; + } else { + return undefined; + } +} diff --git a/apps/dev-launchpad/public/index.html b/apps/dev-launchpad/public/index.html index b169d676bb..41fc2ee64d 100644 --- a/apps/dev-launchpad/public/index.html +++ b/apps/dev-launchpad/public/index.html @@ -113,6 +113,9 @@

Background services

  • 8119: Freestyle mock
  • +
  • + 8121: S3 mock +