From d35d13db23dbff7e4abed63d50f3b4fea2e5e1b3 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 2 Jul 2026 15:51:19 +1000 Subject: [PATCH] fix: remove presigned branding upload (#3053) --- .../forms/branding-preferences-form.tsx | 17 +- .../o.$orgUrl.settings.branding.tsx | 20 +- .../t.$teamUrl+/settings.branding.tsx | 19 +- apps/remix/server/api/files/files.ts | 29 +-- apps/remix/server/api/files/files.types.ts | 21 -- apps/remix/server/router.ts | 1 - .../api-access-file-upload.spec.ts | 40 ---- .../e2e/branding-logo-optimise.spec.ts | 37 +++ .../e2e/branding-logo-upload.spec.ts | 225 ++++++++++++++++++ .../app-tests/e2e/signing-branding.spec.ts | 35 +++ packages/lib/constants/branding.ts | 10 + .../branding/store-branding-logo.ts | 26 ++ packages/lib/universal/upload/put-file.ts | 72 +----- packages/lib/utils/images/logo.ts | 12 + .../trpc/server/organisation-router/router.ts | 2 + .../update-organisation-branding-logo.ts | 69 ++++++ ...update-organisation-branding-logo.types.ts | 17 ++ .../update-organisation-settings.ts | 2 - .../update-organisation-settings.types.ts | 1 - packages/trpc/server/team-router/router.ts | 2 + .../team-router/update-team-branding-logo.ts | 69 ++++++ .../update-team-branding-logo.types.ts | 17 ++ .../team-router/update-team-settings.ts | 2 - .../team-router/update-team-settings.types.ts | 1 - packages/trpc/utils/zod-form-data.ts | 20 ++ 25 files changed, 575 insertions(+), 191 deletions(-) create mode 100644 packages/app-tests/e2e/branding-logo-optimise.spec.ts create mode 100644 packages/app-tests/e2e/branding-logo-upload.spec.ts create mode 100644 packages/lib/server-only/branding/store-branding-logo.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation-branding-logo.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation-branding-logo.types.ts create mode 100644 packages/trpc/server/team-router/update-team-branding-logo.ts create mode 100644 packages/trpc/server/team-router/update-team-branding-logo.types.ts diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index 561fb5512..ef3ff6b34 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -1,5 +1,10 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { + BRANDING_LOGO_ALLOWED_TYPES, + BRANDING_LOGO_MAX_SIZE_BYTES, + BRANDING_LOGO_MAX_SIZE_MB, +} from '@documenso/lib/constants/branding'; import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme'; import { ZCssVarsSchema } from '@documenso/lib/types/css-vars'; import { cn } from '@documenso/ui/lib/utils'; @@ -23,15 +28,15 @@ import { useCspNonce } from '~/utils/nonce'; import { FormStickySaveBar } from './form-sticky-save-bar'; -const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB -const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; - const ZBrandingPreferencesFormSchema = z.object({ brandingEnabled: z.boolean().nullable(), brandingLogo: z .instanceof(File) - .refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') - .refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted') + .refine( + (file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES, + `File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`, + ) + .refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted') .nullish(), brandingUrl: z.string().url().optional().or(z.literal('')), brandingCompanyDetails: z.string().max(500).optional(), @@ -245,7 +250,7 @@ export function BrandingPreferencesForm({ { const file = e.target.files?.[0]; diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx index 707c4ad96..c719f087f 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx @@ -1,7 +1,6 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import { trpc } from '@documenso/trpc/react'; @@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() { const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation(); + const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation(); + const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => { try { const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data; - let uploadedBrandingLogo: string | undefined; + // Upload (or clear) the logo through the dedicated, server-validated route. + if (brandingLogo instanceof File || brandingLogo === null) { + const formData = new FormData(); - if (brandingLogo) { - uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); - } + formData.append('payload', JSON.stringify({ organisationId: organisation.id })); - // Empty the branding logo if the user unsets it. - if (brandingLogo === null) { - uploadedBrandingLogo = ''; + if (brandingLogo instanceof File) { + formData.append('brandingLogo', brandingLogo); + } + + await updateOrganisationBrandingLogo(formData); } const result = await updateOrganisationSettings({ organisationId: organisation.id, data: { brandingEnabled: brandingEnabled ?? undefined, - brandingLogo: uploadedBrandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx index 0401d4da2..6035941d3 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx @@ -1,6 +1,5 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import { trpc } from '@documenso/trpc/react'; @@ -38,6 +37,7 @@ export default function TeamsSettingsPage() { }); const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation(); + const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation(); const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED(); @@ -48,22 +48,23 @@ export default function TeamsSettingsPage() { try { const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data; - let uploadedBrandingLogo: string | undefined; + // Upload (or clear) the logo through the dedicated, server-validated route. + if (brandingLogo instanceof File || brandingLogo === null) { + const formData = new FormData(); - if (brandingLogo) { - uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); - } + formData.append('payload', JSON.stringify({ teamId: team.id })); - // Empty the branding logo if the user unsets it. - if (brandingLogo === null) { - uploadedBrandingLogo = ''; + if (brandingLogo instanceof File) { + formData.append('brandingLogo', brandingLogo); + } + + await updateTeamBrandingLogo(formData); } const result = await updateTeamSettings({ teamId: team.id, data: { brandingEnabled, - brandingLogo: uploadedBrandingLogo, brandingUrl: brandingUrl || null, brandingCompanyDetails: brandingCompanyDetails || null, brandingColors, diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts index 6885c7bfb..bbca38885 100644 --- a/apps/remix/server/api/files/files.ts +++ b/apps/remix/server/api/files/files.ts @@ -1,9 +1,8 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { AppError } from '@documenso/lib/errors/app-error'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; -import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { prisma } from '@documenso/prisma'; import { sValidator } from '@hono/standard-validator'; import type { Prisma } from '@prisma/client'; @@ -12,14 +11,11 @@ import { Hono } from 'hono'; import type { HonoEnv } from '../../router'; import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers'; import { - isAllowedUploadContentType, - type TGetPresignedPostUrlResponse, ZGetEnvelopeItemFileDownloadRequestParamsSchema, ZGetEnvelopeItemFileRequestParamsSchema, ZGetEnvelopeItemFileRequestQuerySchema, ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema, ZGetEnvelopeItemFileTokenRequestParamsSchema, - ZGetPresignedPostUrlRequestSchema, ZUploadPdfRequestSchema, } from './files.types'; import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf'; @@ -61,29 +57,6 @@ export const filesRoute = new Hono() return c.json({ error: 'Upload failed' }, 500); } }) - .post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => { - const userId = await resolveFileUploadUserId(c); - - if (!userId) { - return c.json({ error: 'Unauthorized' }, 401); - } - - const { fileName, contentType } = c.req.valid('json'); - - if (!isAllowedUploadContentType(contentType)) { - return c.json({ error: 'Unsupported content type' }, 400); - } - - try { - const { key, url } = await getPresignPostUrl(fileName, contentType, userId); - - return c.json({ key, url } satisfies TGetPresignedPostUrlResponse); - } catch (err) { - console.error(err); - - throw new AppError(AppErrorCode.UNKNOWN_ERROR); - } - }) .get( '/envelope/:envelopeId/envelopeItem/:envelopeItemId', sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema), diff --git a/apps/remix/server/api/files/files.types.ts b/apps/remix/server/api/files/files.types.ts index 99f775274..28cb5ded2 100644 --- a/apps/remix/server/api/files/files.types.ts +++ b/apps/remix/server/api/files/files.types.ts @@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({ export type TUploadPdfRequest = z.infer; export type TUploadPdfResponse = z.infer; -export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const; - -export const isAllowedUploadContentType = (contentType: string): boolean => { - const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase(); - - return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType); -}; - -export const ZGetPresignedPostUrlRequestSchema = z.object({ - fileName: z.string().min(1), - contentType: z.string().min(1), -}); - -export const ZGetPresignedPostUrlResponseSchema = z.object({ - key: z.string().min(1), - url: z.string().min(1), -}); - -export type TGetPresignedPostUrlRequest = z.infer; -export type TGetPresignedPostUrlResponse = z.infer; - export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({ envelopeId: z.string().min(1), envelopeItemId: z.string().min(1), diff --git a/apps/remix/server/router.ts b/apps/remix/server/router.ts index 19747d4b2..8c9138404 100644 --- a/apps/remix/server/router.ts +++ b/apps/remix/server/router.ts @@ -105,7 +105,6 @@ app.route('/api/auth', auth); // Files route. app.use('/api/files/upload-pdf', fileRateLimitMiddleware); -app.use('/api/files/presigned-post-url', fileRateLimitMiddleware); app.route('/api/files', filesRoute); // AI route. diff --git a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-upload.spec.ts b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-upload.spec.ts index 21b58d737..6aac373cf 100644 --- a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-upload.spec.ts +++ b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-upload.spec.ts @@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => { expect(res.status()).toBe(401); }); - test('rejects an unauthenticated presigned-post-url request', async ({ request }) => { - const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, { - headers: { 'Content-Type': 'application/json' }, - data: { fileName: 'test.pdf', contentType: 'application/pdf' }, - }); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(401); - }); - - test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => { - const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, { - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer not-a-real-token', - }, - data: { fileName: 'test.pdf', contentType: 'application/pdf' }, - }); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(401); - }); - - test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => { - const { user, team } = await seedUser(); - const presignToken = await createPresignTokenForUser(user.id, team.id); - - const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${presignToken}`, - }, - data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' }, - }); - - // Authenticated, but the content type is not on the allow-list. - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(400); - }); - test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => { const { user, team } = await seedUser(); const presignToken = await createPresignTokenForUser(user.id, team.id); diff --git a/packages/app-tests/e2e/branding-logo-optimise.spec.ts b/packages/app-tests/e2e/branding-logo-optimise.spec.ts new file mode 100644 index 000000000..8f26de966 --- /dev/null +++ b/packages/app-tests/e2e/branding-logo-optimise.spec.ts @@ -0,0 +1,37 @@ +import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo'; +import { expect, test } from '@playwright/test'; +import sharp from 'sharp'; + +const makePng = async (width = 1200, height = 1200) => + sharp({ + create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } }, + }) + .png() + .toBuffer(); + +test.describe('optimiseBrandingLogo', () => { + test('re-encodes a valid image to a PNG buffer', async () => { + const input = await makePng(); + + const output = await optimiseBrandingLogo(input); + + const metadata = await sharp(output).metadata(); + + expect(metadata.format).toBe('png'); + }); + + test('bounds the image to a maximum of 512px on its largest side', async () => { + const input = await makePng(2000, 1000); + + const output = await optimiseBrandingLogo(input); + + const metadata = await sharp(output).metadata(); + + expect(metadata.width).toBeLessThanOrEqual(512); + expect(metadata.height).toBeLessThanOrEqual(512); + }); + + test('rejects input that is not a valid image', async () => { + await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow(); + }); +}); diff --git a/packages/app-tests/e2e/branding-logo-upload.spec.ts b/packages/app-tests/e2e/branding-logo-upload.spec.ts new file mode 100644 index 000000000..2b7bd6602 --- /dev/null +++ b/packages/app-tests/e2e/branding-logo-upload.spec.ts @@ -0,0 +1,225 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, type Page, test } from '@playwright/test'; + +import { apiSignin } from './fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +const LOGO_PATH = path.join(__dirname, '../../assets/logo.png'); + +type MultipartFile = { name: string; mimeType: string; buffer: Buffer }; + +const enableBrandingAndUpload = async (page: Page) => { + // Enable custom branding so the file input is no longer disabled. + await page.getByTestId('enable-branding').click(); + await page.getByRole('option', { name: 'Yes' }).click(); + + // Upload the logo file through the real multipart route. + await page.locator('input[type="file"]').setInputFiles(LOGO_PATH); + + await page.getByRole('button', { name: 'Save changes' }).first().click(); + await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); +}; + +/** + * POST a logo straight to the dedicated multipart tRPC route using the + * authenticated browser cookies. This bypasses the client-side form validation, + * which is the only way to exercise the server-side image validation / + * sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate. + */ +const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => { + const multipart: Record = { + payload: JSON.stringify({ organisationId }), + }; + + if (file) { + multipart.brandingLogo = file; + } + + return await page + .context() + .request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart }); +}; + +/** + * Grant the organisation the custom-branding entitlement. The positive branding + * flows require it whenever billing is enabled; with billing disabled the gate is + * bypassed, so this keeps these tests valid in both modes. + */ +const grantCustomBranding = async (organisationClaimId: string) => { + await prisma.organisationClaim.update({ + where: { id: organisationClaimId }, + data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } }, + }); +}; + +test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => { + const { user, organisation } = await seedUser({ isPersonalOrganisation: false }); + + await grantCustomBranding(organisation.organisationClaim.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/o/${organisation.url}/settings/branding`, + }); + + await enableBrandingAndUpload(page); + + const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({ + where: { id: organisation.organisationGlobalSettingsId }, + }); + + expect(settings.brandingLogo).toBeTruthy(); + + const parsed = JSON.parse(settings.brandingLogo); + expect(parsed).toHaveProperty('type'); + expect(parsed).toHaveProperty('data'); +}); + +test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => { + const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false }); + + await grantCustomBranding(organisation.organisationClaim.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/settings/branding`, + }); + + await enableBrandingAndUpload(page); + + // TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it + // through the team relation. + const teamWithSettings = await prisma.team.findUniqueOrThrow({ + where: { id: team.id }, + include: { teamGlobalSettings: true }, + }); + + expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy(); + + const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? ''); + expect(parsed).toHaveProperty('type'); + expect(parsed).toHaveProperty('data'); +}); + +test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => { + const { user, organisation } = await seedUser({ isPersonalOrganisation: false }); + + await grantCustomBranding(organisation.organisationClaim.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/o/${organisation.url}/settings/branding`, + }); + + await enableBrandingAndUpload(page); + + // Confirm the logo was stored before we clear it. + const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({ + where: { id: organisation.organisationGlobalSettingsId }, + }); + + expect(settings.brandingLogo).toBeTruthy(); + + // Remove the logo and save again. + await page.getByRole('button', { name: 'Remove' }).click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); + + // Clearing the logo persists an empty string via the dedicated route. + await expect + .poll(async () => { + const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({ + where: { id: organisation.organisationGlobalSettingsId }, + }); + + return updated.brandingLogo; + }) + .toBe(''); +}); + +test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => { + const { user, organisation } = await seedUser({ isPersonalOrganisation: false }); + + await grantCustomBranding(organisation.organisationClaim.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/o/${organisation.url}/settings/branding`, + }); + + // Positive control: a genuine PNG is accepted and stored. This also proves the + // direct multipart request shape matches what the route expects. + const validResponse = await postOrganisationBrandingLogo(page, organisation.id, { + name: 'logo.png', + mimeType: 'image/png', + buffer: fs.readFileSync(LOGO_PATH), + }); + + expect(validResponse.ok()).toBeTruthy(); + + const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({ + where: { id: organisation.organisationGlobalSettingsId }, + }); + + expect(afterValid.brandingLogo).toBeTruthy(); + + // Bytes that pass the MIME/size allowlist but are not a real image must be + // rejected by the server (the `sharp` re-encode) without changing stored state. + const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, { + name: 'fake.png', + mimeType: 'image/png', + buffer: Buffer.from('this is definitely not a valid png'), + }); + + expect(invalidResponse.ok()).toBeFalsy(); + expect(invalidResponse.status()).toBeGreaterThanOrEqual(400); + expect(invalidResponse.status()).toBeLessThan(500); + + const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({ + where: { id: organisation.organisationGlobalSettingsId }, + }); + + // The previously stored, valid logo is left untouched by the rejected upload. + expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo); +}); + +test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => { + // The entitlement is only enforced when billing is enabled; with billing off + // the check is intentionally skipped server-side, so this can't be exercised. + test.skip( + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true', + 'Entitlement is only enforced when billing is enabled.', + ); + + // Seeded organisations have no `allowCustomBranding` claim flag. + const { user, organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/o/${organisation.url}/settings/branding`, + }); + + const response = await postOrganisationBrandingLogo(page, organisation.id, { + name: 'logo.png', + mimeType: 'image/png', + buffer: fs.readFileSync(LOGO_PATH), + }); + + expect(response.ok()).toBeFalsy(); + + const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({ + where: { id: organisation.organisationGlobalSettingsId }, + }); + + expect(settings.brandingLogo).toBeFalsy(); +}); diff --git a/packages/app-tests/e2e/signing-branding.spec.ts b/packages/app-tests/e2e/signing-branding.spec.ts index 9cd9fbf85..1292d2f83 100644 --- a/packages/app-tests/e2e/signing-branding.spec.ts +++ b/packages/app-tests/e2e/signing-branding.spec.ts @@ -142,3 +142,38 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0); await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0); }); + +test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({ + page, +}) => { + const { user, team, organisation } = await seedUser(); + + await enableOrganisationBranding({ + organisationGlobalSettingsId: organisation.organisationGlobalSettingsId, + }); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: ['enabled-disabled-branding-signer@test.documenso.com'], + fields: [FieldType.SIGNATURE], + updateDocumentOptions: { internalVersion: 2 }, + }); + + // Branding enabled → the custom logo is rendered on the signing page. + await page.goto(`/sign/${recipients[0].token}`); + await expectPlainBrandingLogo(page, `${team.name}'s Logo`); + + // Disable branding while keeping the stored logo (the team inherits this). + await prisma.organisationGlobalSettings.update({ + where: { id: organisation.organisationGlobalSettingsId }, + data: { brandingEnabled: false }, + }); + + // Branding disabled → the custom logo is gone and the Documenso fallback + // (an internal link to "/") is shown instead. + await page.goto(`/sign/${recipients[0].token}`); + + await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0); + await expect(page.locator('a[href="/"]').first()).toBeVisible(); +}); diff --git a/packages/lib/constants/branding.ts b/packages/lib/constants/branding.ts index f50cc7098..0f00237b7 100644 --- a/packages/lib/constants/branding.ts +++ b/packages/lib/constants/branding.ts @@ -9,3 +9,13 @@ * cap so a malicious or runaway payload can't exhaust PostCSS/server memory. */ export const BRANDING_CSS_MAX_LENGTH = 256 * 1024; + +/** + * Branding logo upload constraints. Enforced server-side at the TRPC request + * boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX. + */ +export const BRANDING_LOGO_MAX_SIZE_MB = 5; + +export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024; + +export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp']; diff --git a/packages/lib/server-only/branding/store-branding-logo.ts b/packages/lib/server-only/branding/store-branding-logo.ts new file mode 100644 index 000000000..af0601aec --- /dev/null +++ b/packages/lib/server-only/branding/store-branding-logo.ts @@ -0,0 +1,26 @@ +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { putFileServerSide } from '../../universal/upload/put-file.server'; +import { optimiseBrandingLogo } from '../../utils/images/logo'; + +/** + * Validate, sanitise and store an uploaded branding logo. Returns the + * `JSON.stringify({ type, data })` reference persisted in the `brandingLogo` + * column (the same format the serving endpoints already expect). + */ +export const buildBrandingLogoData = async (file: File): Promise => { + const buffer = Buffer.from(await file.arrayBuffer()); + + const optimised = await optimiseBrandingLogo(buffer).catch(() => { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'The branding logo must be a valid image file.', + }); + }); + + const documentData = await putFileServerSide({ + name: 'branding-logo.png', + type: 'image/png', + arrayBuffer: async () => Promise.resolve(optimised), + }); + + return JSON.stringify(documentData); +}; diff --git a/packages/lib/universal/upload/put-file.ts b/packages/lib/universal/upload/put-file.ts index 5a7eeecc0..46b53a861 100644 --- a/packages/lib/universal/upload/put-file.ts +++ b/packages/lib/universal/upload/put-file.ts @@ -1,10 +1,5 @@ -import { env } from '@documenso/lib/utils/env'; -import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types'; -import { DocumentDataType } from '@prisma/client'; -import { base64 } from '@scure/base'; -import { match } from 'ts-pattern'; +import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types'; -import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError } from '../../errors/app-error'; type File = { @@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => { return result; }; - -/** - * Uploads a file to the appropriate storage location. - */ -export const putFile = async (file: File, options?: PutFileOptions) => { - const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT'); - - return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT) - .with('s3', async () => putFileInObjectStorage(file, {}, options)) - .with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options)) - .otherwise(async () => putFileInDatabase(file)); -}; - -const putFileInDatabase = async (file: File) => { - const contents = await file.arrayBuffer(); - - const binaryData = new Uint8Array(contents); - - const asciiData = base64.encode(binaryData); - - return { - type: DocumentDataType.BYTES_64, - data: asciiData, - }; -}; - -const putFileInObjectStorage = async (file: File, extraHeaders: Record, options?: PutFileOptions) => { - const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...buildUploadAuthHeaders(options), - }, - body: JSON.stringify({ - fileName: file.name, - contentType: file.type, - }), - }); - - if (!getPresignedUrlResponse.ok) { - throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`); - } - - const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json(); - - const body = await file.arrayBuffer(); - - const response = await fetch(url, { - method: 'PUT', - headers: { - 'Content-Type': 'application/octet-stream', - ...extraHeaders, - }, - body, - }); - - if (!response.ok) { - throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`); - } - - return { - type: DocumentDataType.S3_PATH, - data: key, - }; -}; diff --git a/packages/lib/utils/images/logo.ts b/packages/lib/utils/images/logo.ts index b81f7c012..98b2edebb 100644 --- a/packages/lib/utils/images/logo.ts +++ b/packages/lib/utils/images/logo.ts @@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => { content, }; }; + +/** + * Validate and sanitise an uploaded branding logo. Re-encoding through `sharp` + * proves the bytes are a real raster image and strips any embedded payloads. + * Throws if the input cannot be parsed as an image. + */ +export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise => { + return await sharp(input) + .resize(512, 512, { fit: 'inside', withoutEnlargement: true }) + .png({ quality: 80 }) + .toBuffer(); +}; diff --git a/packages/trpc/server/organisation-router/router.ts b/packages/trpc/server/organisation-router/router.ts index 3a66faab0..e0c1bbba4 100644 --- a/packages/trpc/server/organisation-router/router.ts +++ b/packages/trpc/server/organisation-router/router.ts @@ -20,6 +20,7 @@ import { getOrganisationsRoute } from './get-organisations'; import { leaveOrganisationRoute } from './leave-organisation'; import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite'; import { updateOrganisationRoute } from './update-organisation'; +import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo'; import { updateOrganisationGroupRoute } from './update-organisation-group'; import { updateOrganisationMemberRoute } from './update-organisation-members'; import { updateOrganisationSettingsRoute } from './update-organisation-settings'; @@ -55,6 +56,7 @@ export const organisationRouter = router({ }, settings: { update: updateOrganisationSettingsRoute, + updateBrandingLogo: updateOrganisationBrandingLogoRoute, }, internal: { getOrganisationSession: getOrganisationSessionRoute, diff --git a/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts b/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts new file mode 100644 index 000000000..a4ee8ffcd --- /dev/null +++ b/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts @@ -0,0 +1,69 @@ +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo'; +import { getOrganisationClaim } from '@documenso/lib/server-only/organisation/get-organisation-claims'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZUpdateOrganisationBrandingLogoRequestSchema, + ZUpdateOrganisationBrandingLogoResponseSchema, +} from './update-organisation-branding-logo.types'; + +export const updateOrganisationBrandingLogoRoute = authenticatedProcedure + .input(ZUpdateOrganisationBrandingLogoRequestSchema) + .output(ZUpdateOrganisationBrandingLogoResponseSchema) + .mutation(async ({ ctx, input }) => { + const { user } = ctx; + const { payload, brandingLogo } = input; + const { organisationId } = payload; + + ctx.logger.info({ + input: { + organisationId, + }, + }); + + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ + organisationId, + userId: user.id, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + }); + + if (!organisation) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to update this organisation.', + }); + } + + // Setting a logo requires the custom-branding entitlement; clearing it is + // always allowed so a downgraded organisation can still remove its logo. + if (brandingLogo && IS_BILLING_ENABLED()) { + const claim = await getOrganisationClaim({ organisationId }); + + if (claim.flags?.allowCustomBranding !== true) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Your plan does not allow custom branding.', + }); + } + } + + const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : ''; + + await prisma.organisation.update({ + where: { + id: organisation.id, + }, + data: { + organisationGlobalSettings: { + update: { + brandingLogo: brandingLogoValue, + }, + }, + }, + }); + }); diff --git a/packages/trpc/server/organisation-router/update-organisation-branding-logo.types.ts b/packages/trpc/server/organisation-router/update-organisation-branding-logo.types.ts new file mode 100644 index 000000000..059902921 --- /dev/null +++ b/packages/trpc/server/organisation-router/update-organisation-branding-logo.types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; + +import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data'; + +export const ZUpdateOrganisationBrandingLogoRequestSchema = zodFormData({ + payload: zfd.json( + z.object({ + organisationId: z.string(), + }), + ), + brandingLogo: zfdBrandingImageFile().optional(), +}); + +export const ZUpdateOrganisationBrandingLogoResponseSchema = z.void(); + +export type TUpdateOrganisationBrandingLogoRequest = z.infer; diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.ts b/packages/trpc/server/organisation-router/update-organisation-settings.ts index 625f79114..156e40e69 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.ts @@ -44,7 +44,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure // Branding related settings. brandingEnabled, - brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, @@ -174,7 +173,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure // Branding related settings. brandingEnabled, - brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors, diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts index d7b7d1c0d..62caa07fc 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts @@ -32,7 +32,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({ // Branding related settings. brandingEnabled: z.boolean().optional(), - brandingLogo: z.string().optional(), brandingUrl: z.string().optional(), brandingCompanyDetails: z.string().optional(), brandingColors: ZCssVarsSchema.nullish(), diff --git a/packages/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts index 26a1d9913..d05e2af13 100644 --- a/packages/trpc/server/team-router/router.ts +++ b/packages/trpc/server/team-router/router.ts @@ -25,6 +25,7 @@ import { ZUpdateTeamEmailMutationSchema, } from './schema'; import { updateTeamRoute } from './update-team'; +import { updateTeamBrandingLogoRoute } from './update-team-branding-logo'; import { updateTeamGroupRoute } from './update-team-group'; import { updateTeamMemberRoute } from './update-team-member'; import { updateTeamSettingsRoute } from './update-team-settings'; @@ -50,6 +51,7 @@ export const teamRouter = router({ }, settings: { update: updateTeamSettingsRoute, + updateBrandingLogo: updateTeamBrandingLogoRoute, }, // Old routes (to be migrated) diff --git a/packages/trpc/server/team-router/update-team-branding-logo.ts b/packages/trpc/server/team-router/update-team-branding-logo.ts new file mode 100644 index 000000000..2196bb068 --- /dev/null +++ b/packages/trpc/server/team-router/update-team-branding-logo.ts @@ -0,0 +1,69 @@ +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo'; +import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZUpdateTeamBrandingLogoRequestSchema, + ZUpdateTeamBrandingLogoResponseSchema, +} from './update-team-branding-logo.types'; + +export const updateTeamBrandingLogoRoute = authenticatedProcedure + .input(ZUpdateTeamBrandingLogoRequestSchema) + .output(ZUpdateTeamBrandingLogoResponseSchema) + .mutation(async ({ ctx, input }) => { + const { user } = ctx; + const { payload, brandingLogo } = input; + const { teamId } = payload; + + ctx.logger.info({ + input: { + teamId, + }, + }); + + const team = await prisma.team.findFirst({ + where: buildTeamWhereQuery({ + teamId, + userId: user.id, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), + }); + + if (!team) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to update this team.', + }); + } + + // Setting a logo requires the custom-branding entitlement; clearing it is + // always allowed so a downgraded team can still remove its logo. + if (brandingLogo && IS_BILLING_ENABLED()) { + const claim = await getOrganisationClaimByTeamId({ teamId }); + + if (claim.flags?.allowCustomBranding !== true) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Your plan does not allow custom branding.', + }); + } + } + + const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : ''; + + await prisma.team.update({ + where: { + id: team.id, + }, + data: { + teamGlobalSettings: { + update: { + brandingLogo: brandingLogoValue, + }, + }, + }, + }); + }); diff --git a/packages/trpc/server/team-router/update-team-branding-logo.types.ts b/packages/trpc/server/team-router/update-team-branding-logo.types.ts new file mode 100644 index 000000000..171537eaa --- /dev/null +++ b/packages/trpc/server/team-router/update-team-branding-logo.types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; + +import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data'; + +export const ZUpdateTeamBrandingLogoRequestSchema = zodFormData({ + payload: zfd.json( + z.object({ + teamId: z.number(), + }), + ), + brandingLogo: zfdBrandingImageFile().optional(), +}); + +export const ZUpdateTeamBrandingLogoResponseSchema = z.void(); + +export type TUpdateTeamBrandingLogoRequest = z.infer; diff --git a/packages/trpc/server/team-router/update-team-settings.ts b/packages/trpc/server/team-router/update-team-settings.ts index c5db1d12a..c8a81b4b8 100644 --- a/packages/trpc/server/team-router/update-team-settings.ts +++ b/packages/trpc/server/team-router/update-team-settings.ts @@ -42,7 +42,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure // Branding related settings. brandingEnabled, - brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, @@ -176,7 +175,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure // Branding related settings. brandingEnabled, - brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors, diff --git a/packages/trpc/server/team-router/update-team-settings.types.ts b/packages/trpc/server/team-router/update-team-settings.types.ts index 31ec4c5b0..72990ce77 100644 --- a/packages/trpc/server/team-router/update-team-settings.types.ts +++ b/packages/trpc/server/team-router/update-team-settings.types.ts @@ -35,7 +35,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({ // Branding related settings. brandingEnabled: z.boolean().nullish(), - brandingLogo: z.string().nullish(), brandingUrl: z.string().nullish(), brandingCompanyDetails: z.string().nullish(), brandingColors: ZCssVarsSchema.nullish(), diff --git a/packages/trpc/utils/zod-form-data.ts b/packages/trpc/utils/zod-form-data.ts index d62c50635..82f3b39d2 100644 --- a/packages/trpc/utils/zod-form-data.ts +++ b/packages/trpc/utils/zod-form-data.ts @@ -1,4 +1,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; +import { + BRANDING_LOGO_ALLOWED_TYPES, + BRANDING_LOGO_MAX_SIZE_BYTES, + BRANDING_LOGO_MAX_SIZE_MB, +} from '@documenso/lib/constants/branding'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import type { ZodRawShape } from 'zod'; import z from 'zod'; @@ -17,6 +22,21 @@ export const zfdFile = () => { }); }; +/** + * A `zfd.file()` schema constrained to branding-logo images: size-limited and + * restricted to a MIME allowlist. Use for server-side branding logo uploads. + */ +export const zfdBrandingImageFile = () => { + return zfd + .file() + .refine((file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES, { + message: `File cannot be larger than ${BRANDING_LOGO_MAX_SIZE_MB}MB`, + }) + .refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), { + message: 'File must be a JPG, PNG, or WebP image', + }); +}; + /** * This helper takes the place of the `z.object` at the root of your schema. * It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`