forked from StellarSplit/StellarSplit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathupload-policy.ts
More file actions
134 lines (118 loc) · 4.09 KB
/
Copy pathupload-policy.ts
File metadata and controls
134 lines (118 loc) · 4.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import { BadRequestException } from '@nestjs/common';
export interface UploadPolicy {
allowedMimeTypes: string[];
maxFileSize: number;
keyPrefix: string;
allowedExtensions: string[];
filenameSanitization: {
maxLength: number;
allowedChars: RegExp;
replacementChar: string;
};
downloadHeaders: {
cacheControl: string;
contentDisposition: string;
};
urlExpiration: {
upload: number;
download: number;
};
}
export const DEFAULT_UPLOAD_POLICY: UploadPolicy = {
allowedMimeTypes: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf'
],
maxFileSize: 10 * 1024 * 1024, // 10MB
keyPrefix: 'receipts',
allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf'],
filenameSanitization: {
maxLength: 255,
allowedChars: /^[a-zA-Z0-9._-]$/,
replacementChar: '_'
},
downloadHeaders: {
cacheControl: 'max-age=31536000', // 1 year
contentDisposition: 'inline'
},
urlExpiration: {
upload: 3600, // 1 hour
download: 3600 // 1 hour
}
};
export class UploadPolicyValidator {
constructor(private policy: UploadPolicy = DEFAULT_UPLOAD_POLICY) {}
validateMimeType(mimeType: string): void {
if (!this.policy.allowedMimeTypes.includes(mimeType)) {
throw new BadRequestException(
`File type ${mimeType} is not allowed. Allowed types: ${this.policy.allowedMimeTypes.join(', ')}`
);
}
}
validateFileSize(fileSize: number): void {
if (fileSize > this.policy.maxFileSize) {
throw new BadRequestException(
`File size ${fileSize} exceeds maximum allowed size of ${this.policy.maxFileSize} bytes`
);
}
}
validateFileExtension(filename: string): void {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
if (!this.policy.allowedExtensions.includes(ext)) {
throw new BadRequestException(
`File extension ${ext} is not allowed. Allowed extensions: ${this.policy.allowedExtensions.join(', ')}`
);
}
}
sanitizeFilename(filename: string): string {
const replacement = this.policy.filenameSanitization.replacementChar;
// Neutralize path-traversal tokens ("../") so the dot-dot sequence cannot
// survive into the object key, then map any remaining path separators and
// disallowed characters to the replacement char.
let cleaned = filename
.replace(/\.\.\//g, replacement) // collapse "../" traversal tokens
.replace(/[/\\]/g, replacement) // remaining path separators
.replace(/\.\./g, replacement) // any leftover dot-dot sequences
.replace(/[^a-zA-Z0-9._-]/g, replacement); // anything outside the allowed set
// Truncate if too long
cleaned =
cleaned.length > this.policy.filenameSanitization.maxLength
? cleaned.substring(0, this.policy.filenameSanitization.maxLength)
: cleaned;
// Ensure something meaningful survived. The `allowedChars` regex matches a
// single permitted character; we apply it per-character (it is intentionally
// non-global so `.test` stays stateless) to confirm at least one character
// is both allowed AND not merely the replacement char. A result made up
// entirely of replacement characters (e.g. "!!!" -> "___") carries no usable
// original content and falls back to a safe default.
const hasMeaningfulChar = cleaned
.split('')
.some(
(char) =>
char !== replacement &&
this.policy.filenameSanitization.allowedChars.test(char),
);
if (!cleaned || !hasMeaningfulChar) {
return 'file';
}
return cleaned;
}
generateObjectKey(sanitizedFilename: string, uuid: string): string {
return `${this.policy.keyPrefix}/${uuid}-${sanitizedFilename}`;
}
getDownloadHeaders(): Record<string, string> {
return {
'Cache-Control': this.policy.downloadHeaders.cacheControl,
'Content-Disposition': this.policy.downloadHeaders.contentDisposition
};
}
getUploadExpiration(): number {
return this.policy.urlExpiration.upload;
}
getDownloadExpiration(): number {
return this.policy.urlExpiration.download;
}
}