mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 17:04:12 +10:00
fix: remove presigned branding upload (#3053)
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
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 { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -23,15 +28,15 @@ import { useCspNonce } from '~/utils/nonce';
|
|||||||
|
|
||||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
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({
|
const ZBrandingPreferencesFormSchema = z.object({
|
||||||
brandingEnabled: z.boolean().nullable(),
|
brandingEnabled: z.boolean().nullable(),
|
||||||
brandingLogo: z
|
brandingLogo: z
|
||||||
.instanceof(File)
|
.instanceof(File)
|
||||||
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
|
.refine(
|
||||||
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
|
(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(),
|
.nullish(),
|
||||||
brandingUrl: z.string().url().optional().or(z.literal('')),
|
brandingUrl: z.string().url().optional().or(z.literal('')),
|
||||||
brandingCompanyDetails: z.string().max(500).optional(),
|
brandingCompanyDetails: z.string().max(500).optional(),
|
||||||
@@ -245,7 +250,7 @@ export function BrandingPreferencesForm({
|
|||||||
<FormControl className="relative">
|
<FormControl className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
|
||||||
disabled={!isBrandingEnabled}
|
disabled={!isBrandingEnabled}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
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 { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
|
|
||||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
|
||||||
|
|
||||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
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) {
|
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
|
||||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty the branding logo if the user unsets it.
|
if (brandingLogo instanceof File) {
|
||||||
if (brandingLogo === null) {
|
formData.append('brandingLogo', brandingLogo);
|
||||||
uploadedBrandingLogo = '';
|
}
|
||||||
|
|
||||||
|
await updateOrganisationBrandingLogo(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateOrganisationSettings({
|
const result = await updateOrganisationSettings({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
data: {
|
data: {
|
||||||
brandingEnabled: brandingEnabled ?? undefined,
|
brandingEnabled: brandingEnabled ?? undefined,
|
||||||
brandingLogo: uploadedBrandingLogo,
|
|
||||||
brandingUrl,
|
brandingUrl,
|
||||||
brandingCompanyDetails,
|
brandingCompanyDetails,
|
||||||
brandingColors,
|
brandingColors,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
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 { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -38,6 +37,7 @@ export default function TeamsSettingsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
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();
|
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||||
|
|
||||||
@@ -48,22 +48,23 @@ export default function TeamsSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
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) {
|
formData.append('payload', JSON.stringify({ teamId: team.id }));
|
||||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty the branding logo if the user unsets it.
|
if (brandingLogo instanceof File) {
|
||||||
if (brandingLogo === null) {
|
formData.append('brandingLogo', brandingLogo);
|
||||||
uploadedBrandingLogo = '';
|
}
|
||||||
|
|
||||||
|
await updateTeamBrandingLogo(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateTeamSettings({
|
const result = await updateTeamSettings({
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
data: {
|
data: {
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
brandingLogo: uploadedBrandingLogo,
|
|
||||||
brandingUrl: brandingUrl || null,
|
brandingUrl: brandingUrl || null,
|
||||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||||
brandingColors,
|
brandingColors,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
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 { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
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 { prisma } from '@documenso/prisma';
|
||||||
import { sValidator } from '@hono/standard-validator';
|
import { sValidator } from '@hono/standard-validator';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
|
|||||||
import type { HonoEnv } from '../../router';
|
import type { HonoEnv } from '../../router';
|
||||||
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
|
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
|
||||||
import {
|
import {
|
||||||
isAllowedUploadContentType,
|
|
||||||
type TGetPresignedPostUrlResponse,
|
|
||||||
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
||||||
ZGetEnvelopeItemFileRequestParamsSchema,
|
ZGetEnvelopeItemFileRequestParamsSchema,
|
||||||
ZGetEnvelopeItemFileRequestQuerySchema,
|
ZGetEnvelopeItemFileRequestQuerySchema,
|
||||||
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
|
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
|
||||||
ZGetEnvelopeItemFileTokenRequestParamsSchema,
|
ZGetEnvelopeItemFileTokenRequestParamsSchema,
|
||||||
ZGetPresignedPostUrlRequestSchema,
|
|
||||||
ZUploadPdfRequestSchema,
|
ZUploadPdfRequestSchema,
|
||||||
} from './files.types';
|
} from './files.types';
|
||||||
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
|
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
|
||||||
@@ -61,29 +57,6 @@ export const filesRoute = new Hono<HonoEnv>()
|
|||||||
return c.json({ error: 'Upload failed' }, 500);
|
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(
|
.get(
|
||||||
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
|
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
|
||||||
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
|
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
|
||||||
|
|||||||
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
|
|||||||
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
||||||
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
||||||
|
|
||||||
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<typeof ZGetPresignedPostUrlRequestSchema>;
|
|
||||||
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
|
|
||||||
|
|
||||||
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
|
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
|
||||||
envelopeId: z.string().min(1),
|
envelopeId: z.string().min(1),
|
||||||
envelopeItemId: z.string().min(1),
|
envelopeItemId: z.string().min(1),
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
|
|||||||
|
|
||||||
// Files route.
|
// Files route.
|
||||||
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
|
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
|
||||||
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
|
|
||||||
app.route('/api/files', filesRoute);
|
app.route('/api/files', filesRoute);
|
||||||
|
|
||||||
// AI route.
|
// AI route.
|
||||||
|
|||||||
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
|
|||||||
expect(res.status()).toBe(401);
|
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 }) => {
|
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string | MultipartFile> = {
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -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.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||||
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,3 +9,13 @@
|
|||||||
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
||||||
*/
|
*/
|
||||||
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
|
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'];
|
||||||
|
|||||||
@@ -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<string> => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import { env } from '@documenso/lib/utils/env';
|
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
||||||
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 { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|
||||||
import { AppError } from '../../errors/app-error';
|
import { AppError } from '../../errors/app-error';
|
||||||
|
|
||||||
type File = {
|
type File = {
|
||||||
@@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
|
|||||||
|
|
||||||
return result;
|
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<string, string>, 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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => {
|
|||||||
content,
|
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<Buffer> => {
|
||||||
|
return await sharp(input)
|
||||||
|
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.png({ quality: 80 })
|
||||||
|
.toBuffer();
|
||||||
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { getOrganisationsRoute } from './get-organisations';
|
|||||||
import { leaveOrganisationRoute } from './leave-organisation';
|
import { leaveOrganisationRoute } from './leave-organisation';
|
||||||
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
|
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
|
||||||
import { updateOrganisationRoute } from './update-organisation';
|
import { updateOrganisationRoute } from './update-organisation';
|
||||||
|
import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo';
|
||||||
import { updateOrganisationGroupRoute } from './update-organisation-group';
|
import { updateOrganisationGroupRoute } from './update-organisation-group';
|
||||||
import { updateOrganisationMemberRoute } from './update-organisation-members';
|
import { updateOrganisationMemberRoute } from './update-organisation-members';
|
||||||
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
|
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
|
||||||
@@ -55,6 +56,7 @@ export const organisationRouter = router({
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
update: updateOrganisationSettingsRoute,
|
update: updateOrganisationSettingsRoute,
|
||||||
|
updateBrandingLogo: updateOrganisationBrandingLogoRoute,
|
||||||
},
|
},
|
||||||
internal: {
|
internal: {
|
||||||
getOrganisationSession: getOrganisationSessionRoute,
|
getOrganisationSession: getOrganisationSessionRoute,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof ZUpdateOrganisationBrandingLogoRequestSchema>;
|
||||||
@@ -44,7 +44,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
brandingLogo,
|
|
||||||
brandingUrl,
|
brandingUrl,
|
||||||
brandingCompanyDetails,
|
brandingCompanyDetails,
|
||||||
brandingColors,
|
brandingColors,
|
||||||
@@ -174,7 +173,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
brandingLogo,
|
|
||||||
brandingUrl,
|
brandingUrl,
|
||||||
brandingCompanyDetails,
|
brandingCompanyDetails,
|
||||||
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
|||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
brandingEnabled: z.boolean().optional(),
|
brandingEnabled: z.boolean().optional(),
|
||||||
brandingLogo: z.string().optional(),
|
|
||||||
brandingUrl: z.string().optional(),
|
brandingUrl: z.string().optional(),
|
||||||
brandingCompanyDetails: z.string().optional(),
|
brandingCompanyDetails: z.string().optional(),
|
||||||
brandingColors: ZCssVarsSchema.nullish(),
|
brandingColors: ZCssVarsSchema.nullish(),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
ZUpdateTeamEmailMutationSchema,
|
ZUpdateTeamEmailMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
import { updateTeamRoute } from './update-team';
|
import { updateTeamRoute } from './update-team';
|
||||||
|
import { updateTeamBrandingLogoRoute } from './update-team-branding-logo';
|
||||||
import { updateTeamGroupRoute } from './update-team-group';
|
import { updateTeamGroupRoute } from './update-team-group';
|
||||||
import { updateTeamMemberRoute } from './update-team-member';
|
import { updateTeamMemberRoute } from './update-team-member';
|
||||||
import { updateTeamSettingsRoute } from './update-team-settings';
|
import { updateTeamSettingsRoute } from './update-team-settings';
|
||||||
@@ -50,6 +51,7 @@ export const teamRouter = router({
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
update: updateTeamSettingsRoute,
|
update: updateTeamSettingsRoute,
|
||||||
|
updateBrandingLogo: updateTeamBrandingLogoRoute,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Old routes (to be migrated)
|
// Old routes (to be migrated)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof ZUpdateTeamBrandingLogoRequestSchema>;
|
||||||
@@ -42,7 +42,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
brandingLogo,
|
|
||||||
brandingUrl,
|
brandingUrl,
|
||||||
brandingCompanyDetails,
|
brandingCompanyDetails,
|
||||||
brandingColors,
|
brandingColors,
|
||||||
@@ -176,7 +175,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
brandingLogo,
|
|
||||||
brandingUrl,
|
brandingUrl,
|
||||||
brandingCompanyDetails,
|
brandingCompanyDetails,
|
||||||
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
|||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
brandingEnabled: z.boolean().nullish(),
|
brandingEnabled: z.boolean().nullish(),
|
||||||
brandingLogo: z.string().nullish(),
|
|
||||||
brandingUrl: z.string().nullish(),
|
brandingUrl: z.string().nullish(),
|
||||||
brandingCompanyDetails: z.string().nullish(),
|
brandingCompanyDetails: z.string().nullish(),
|
||||||
brandingColors: ZCssVarsSchema.nullish(),
|
brandingColors: ZCssVarsSchema.nullish(),
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
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 { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||||
import type { ZodRawShape } from 'zod';
|
import type { ZodRawShape } from 'zod';
|
||||||
import z 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.
|
* 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`
|
* It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`
|
||||||
|
|||||||
Reference in New Issue
Block a user