Skip to content

Commit 1caa5d1

Browse files
committed
fix: use same validation in server and client
1 parent f881c48 commit 1caa5d1

File tree

13 files changed

+115
-61
lines changed

13 files changed

+115
-61
lines changed

src/components/Form/FieldUpload/FieldUpload.spec.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const mockFile: FieldUploadValue = {
1717
file: mockFileRaw,
1818
lastModified: mockFileRaw.lastModified,
1919
lastModifiedDate: new Date(mockFileRaw.lastModified),
20-
size: mockFileRaw.size.toString(),
20+
size: mockFileRaw.size,
2121
type: mockFileRaw.type,
2222
name: mockFileRaw.name ?? '',
2323
};
@@ -28,7 +28,7 @@ test('update value', async () => {
2828

2929
render(
3030
<FormMocked
31-
schema={z.object({ file: zFieldUploadValue().optional() })}
31+
schema={z.object({ file: zFieldUploadValue('avatar').optional() })}
3232
useFormOptions={{ defaultValues: { file: undefined } }}
3333
onSubmit={mockedSubmit}
3434
>
@@ -59,7 +59,7 @@ test('default value', async () => {
5959

6060
render(
6161
<FormMocked
62-
schema={z.object({ file: zFieldUploadValue().optional() })}
62+
schema={z.object({ file: zFieldUploadValue('avatar').optional() })}
6363
useFormOptions={{
6464
values: {
6565
file: mockFile,

src/components/Form/FieldUpload/docs.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default {
1616
type FormSchema = z.infer<ReturnType<typeof zFormSchema>>;
1717
const zFormSchema = () =>
1818
z.object({
19-
file: zFieldUploadValue().optional(),
19+
file: zFieldUploadValue('avatar').optional(),
2020
});
2121

2222
const formOptions = {

src/features/account/schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ export const zFormFieldsAccountProfile = () =>
4545
language: true,
4646
})
4747
.extend({
48-
image: zFieldUploadValue(['image']).nullish(),
48+
image: zFieldUploadValue('avatar').nullish(),
4949
});

src/lib/s3/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const useUploadFileMutation = (
2121
...params.getMetadata?.(file),
2222
}),
2323
collection,
24-
fileType: file.type,
24+
type: file.type,
2525
size: file.size,
2626
name: file.name,
2727
});

src/lib/s3/config.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@ import { z } from 'zod';
22

33
import { User } from '@/features/users/schemas';
44

5-
export type FilesCollection = z.infer<ReturnType<typeof zFilesCollection>>;
6-
export const zFilesCollection = () => z.enum(['avatar']);
5+
export type FilesCollectionName = z.infer<
6+
ReturnType<typeof zFilesCollectionName>
7+
>;
8+
export const zFilesCollectionName = () => z.enum(['avatar']);
79

810
// TODO Also use this config in form validation
911
export const FILES_COLLECTIONS_CONFIG = {
1012
avatar: {
1113
getKey: ({ user }) => `avatars/${user.id}`,
12-
fileTypes: ['image/png', 'image/jpg', 'image/jpeg'],
14+
allowedTypes: ['image/png', 'image/jpg', 'image/jpeg'],
1315
maxSize: 1 * 1024 * 1024, // 5MB in bytes,
1416
},
15-
} satisfies Record<
16-
FilesCollection,
17-
{
18-
getKey: (params: { user: User }) => string;
19-
fileTypes?: Array<string>;
20-
maxSize?: number;
21-
}
22-
>;
17+
} satisfies Record<FilesCollectionName, FilesCollectionConfig>;
18+
19+
export type FilesCollectionConfig = {
20+
getKey: (params: { user: User }) => string;
21+
allowedTypes?: Array<string>;
22+
maxSize?: number;
23+
};

src/lib/s3/schemas.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,43 @@
11
import { t } from 'i18next';
22
import { z } from 'zod';
33

4-
import { zFilesCollection } from '@/lib/s3/config';
4+
import { getFieldPath } from '@/lib/form/getFieldPath';
5+
import {
6+
FILES_COLLECTIONS_CONFIG,
7+
FilesCollectionName,
8+
zFilesCollectionName,
9+
} from '@/lib/s3/config';
10+
import { validateFile } from '@/lib/s3/utils';
511
import { zu } from '@/lib/zod/zod-utils';
612

7-
export type UploadFileType = z.infer<typeof zUploadFileType>;
8-
export const zUploadFileType = z.enum(['image', 'application/pdf']);
9-
1013
export type FieldMetadata = z.infer<ReturnType<typeof zFieldMetadata>>;
1114
export const zFieldMetadata = () =>
1215
z.object({
1316
fileUrl: zu.string.nonEmptyNullish(z.string()),
1417
lastModifiedDate: z.date().optional(),
1518
name: zu.string.nonEmptyNullish(z.string()),
16-
size: zu.string.nonEmptyNullish(z.string()),
19+
size: z.coerce.number().nullish(),
1720
type: zu.string.nonEmptyNullish(z.string()),
1821
});
1922

2023
export type FieldUploadValue = z.infer<ReturnType<typeof zFieldUploadValue>>;
21-
export const zFieldUploadValue = (acceptedTypes?: UploadFileType[]) =>
24+
export const zFieldUploadValue = (collection: FilesCollectionName) =>
2225
zFieldMetadata()
2326
.extend({
2427
file: z.instanceof(File).optional(),
2528
lastModified: z.number().optional(),
2629
})
27-
.refine(
28-
(file) => {
29-
if (!acceptedTypes || acceptedTypes.length === 0) {
30-
return true;
31-
}
30+
.superRefine((input, ctx) => {
31+
const config = FILES_COLLECTIONS_CONFIG[collection];
32+
const validateFileReturn = validateFile({ input, config });
3233

33-
return acceptedTypes.some((type) => file.type?.startsWith(type));
34-
},
35-
{
36-
message: t('common:files.invalid', {
37-
acceptedTypes: acceptedTypes?.join(', '),
38-
}),
34+
if (!validateFileReturn.success) {
35+
ctx.addIssue({
36+
code: z.ZodIssueCode.custom,
37+
message: t(`files.errors.${validateFileReturn.error.key}`),
38+
});
3939
}
40-
);
40+
});
4141

4242
export type UploadSignedUrlInput = z.infer<
4343
ReturnType<typeof zUploadSignedUrlInput>
@@ -49,9 +49,9 @@ export const zUploadSignedUrlInput = () =>
4949
*/
5050
metadata: z.string().optional(),
5151
name: z.string(),
52-
fileType: z.string(),
52+
type: z.string(),
5353
size: z.number(),
54-
collection: zFilesCollection(),
54+
collection: zFilesCollectionName(),
5555
});
5656
export type UploadSignedUrlOutput = z.infer<
5757
ReturnType<typeof zUploadSignedUrlOutput>

src/lib/s3/utils.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { FilesCollectionConfig } from '@/lib/s3/config';
2+
import { FieldMetadata } from '@/lib/s3/schemas';
3+
4+
type ValidateReturn =
5+
| { success: true }
6+
| {
7+
success: false;
8+
error: {
9+
message: string;
10+
key: 'tooLarge' | 'typeNotAllowed';
11+
};
12+
};
13+
14+
export const validateFile = (params: {
15+
input: FieldMetadata;
16+
config: FilesCollectionConfig;
17+
}): ValidateReturn => {
18+
if (
19+
params.config.maxSize &&
20+
(params.input.size ?? 0) >= params.config.maxSize
21+
) {
22+
return {
23+
error: {
24+
key: 'tooLarge',
25+
message: `File size is too big ${params.input.size}/${params.config.maxSize}`,
26+
},
27+
success: false,
28+
};
29+
}
30+
31+
if (
32+
params.config.allowedTypes &&
33+
!params.config.allowedTypes.includes(params.input.type ?? '')
34+
) {
35+
return {
36+
error: {
37+
key: 'typeNotAllowed',
38+
message: `Incorrect file type ${params.input.type} (authorized: ${params.config.allowedTypes.join(',')})`,
39+
},
40+
success: false,
41+
};
42+
}
43+
return { success: true };
44+
};

src/locales/ar/common.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,11 @@
2222
},
2323
"filter": "منقي",
2424
"clear": "واضح",
25-
"submit": "يُقدِّم"
25+
"submit": "يُقدِّم",
26+
"files": {
27+
"errors": {
28+
"tooLarge": "ملف كبير جدا",
29+
"typeNotAllowed": "نوع الملف غير مسموح به"
30+
}
31+
}
2632
}

src/locales/en/common.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
"cancelText": "Stay on the page"
2222
},
2323
"files": {
24-
"invalid": "Provided file must be of type : {{acceptedTypes}}"
24+
"errors": {
25+
"tooLarge": "File too large",
26+
"typeNotAllowed": "File type not allowed"
27+
}
2528
},
2629
"filter": "Filter",
2730
"clear": "Clear",

src/locales/fr/common.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
"message": "Vous êtes sur le point de quitter la page sans sauvegarder vos modifications.",
2121
"title": "Quitter la page ?"
2222
},
23-
"files": {
24-
"invalid": "Le fichier doit être de type : {{acceptedTypes}}"
25-
},
2623
"filter": "Filtrer",
2724
"clear": "Effacer",
28-
"submit": "Soumettre"
25+
"submit": "Soumettre",
26+
"files": {
27+
"errors": {
28+
"tooLarge": "Fichier trop lourd",
29+
"typeNotAllowed": "Type de fichier non autorisé"
30+
}
31+
}
2932
}

src/locales/sw/common.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,11 @@
2222
},
2323
"filter": "Chuja",
2424
"clear": "Wazi",
25-
"submit": "Wasilisha"
25+
"submit": "Wasilisha",
26+
"files": {
27+
"errors": {
28+
"tooLarge": "Faili kubwa sana",
29+
"typeNotAllowed": "Aina ya faili hairuhusiwi"
30+
}
31+
}
2632
}

src/server/config/s3.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@ import {
66
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
77

88
import { env } from '@/env.mjs';
9-
import {
10-
FieldMetadata,
11-
UploadFileType,
12-
UploadSignedUrlOutput,
13-
} from '@/lib/s3/schemas';
9+
import { FieldMetadata, UploadSignedUrlOutput } from '@/lib/s3/schemas';
1410

1511
const SIGNED_URL_EXPIRATION_TIME_SECONDS = 60; // 1 minute
1612

@@ -24,8 +20,6 @@ const S3 = new S3Client({
2420
});
2521

2622
type UploadSignedUrlOptions = {
27-
allowedFileTypes?: UploadFileType[];
28-
expiresIn?: number;
2923
/** The tree structure of the file in S3 */
3024
key: string;
3125
metadata?: Record<string, string>;
@@ -41,7 +35,7 @@ export const getS3UploadSignedUrl = async (
4135
Key: options.key,
4236
Metadata: options.metadata,
4337
}),
44-
{ expiresIn: options.expiresIn ?? SIGNED_URL_EXPIRATION_TIME_SECONDS }
38+
{ expiresIn: SIGNED_URL_EXPIRATION_TIME_SECONDS }
4539
);
4640

4741
return {
@@ -62,7 +56,7 @@ export const fetchFileMetadata = async (key: string) => {
6256

6357
return {
6458
fileUrl,
65-
size: fileResponse.ContentLength?.toString(),
59+
size: fileResponse.ContentLength,
6660
type: fileResponse.ContentType,
6761
lastModifiedDate: fileResponse.LastModified
6862
? new Date(fileResponse.LastModified)

src/server/routers/files.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { TRPCError } from '@trpc/server';
2+
import { t } from 'i18next';
23
import { parse } from 'superjson';
34

45
import { FILES_COLLECTIONS_CONFIG } from '@/lib/s3/config';
56
import {
67
zUploadSignedUrlInput,
78
zUploadSignedUrlOutput,
89
} from '@/lib/s3/schemas';
10+
import { validateFile } from '@/lib/s3/utils';
911
import { getS3UploadSignedUrl } from '@/server/config/s3';
1012
import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc';
1113

@@ -31,17 +33,12 @@ export const filesRouter = createTRPCRouter({
3133
});
3234
}
3335

34-
if (config.maxSize && input.size >= config.maxSize) {
35-
throw new TRPCError({
36-
code: 'BAD_REQUEST',
37-
message: `File size is too big ${input.size}/${config.maxSize}`,
38-
});
39-
}
36+
const validateFileResult = validateFile({ input, config });
4037

41-
if (config.fileTypes && !config.fileTypes.includes(input.fileType)) {
38+
if (!validateFileResult.success) {
4239
throw new TRPCError({
4340
code: 'BAD_REQUEST',
44-
message: `Incorrect file type ${input.fileType} (authorized: ${config.fileTypes.join(',')})`,
41+
message: validateFileResult.error.message,
4542
});
4643
}
4744

0 commit comments

Comments
 (0)