bunsane 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +57 -0
- package/LICENSE.md +1 -1
- package/README.md +2 -28
- package/TODO.md +8 -1
- package/bun.lock +3 -0
- package/config/upload.config.ts +135 -0
- package/core/App.ts +119 -4
- package/core/ArcheType.ts +122 -0
- package/core/BatchLoader.ts +100 -0
- package/core/ComponentRegistry.ts +4 -3
- package/core/Components.ts +2 -2
- package/core/Decorators.ts +15 -8
- package/core/Entity.ts +159 -12
- package/core/EntityCache.ts +15 -0
- package/core/EntityHookManager.ts +855 -0
- package/core/EntityManager.ts +12 -2
- package/core/ErrorHandler.ts +64 -7
- package/core/FileValidator.ts +284 -0
- package/core/Query.ts +453 -85
- package/core/RequestContext.ts +24 -0
- package/core/RequestLoaders.ts +65 -0
- package/core/SchedulerManager.ts +710 -0
- package/core/UploadManager.ts +261 -0
- package/core/components/UploadComponent.ts +206 -0
- package/core/decorators/EntityHooks.ts +190 -0
- package/core/decorators/ScheduledTask.ts +83 -0
- package/core/events/EntityLifecycleEvents.ts +177 -0
- package/core/processors/ImageProcessor.ts +423 -0
- package/core/storage/LocalStorageProvider.ts +290 -0
- package/core/storage/StorageProvider.ts +112 -0
- package/database/DatabaseHelper.ts +183 -58
- package/database/index.ts +1 -1
- package/database/sqlHelpers.ts +7 -0
- package/docs/README.md +149 -0
- package/docs/_coverpage.md +36 -0
- package/docs/_sidebar.md +23 -0
- package/docs/api/core.md +568 -0
- package/docs/api/hooks.md +554 -0
- package/docs/api/index.md +222 -0
- package/docs/api/query.md +678 -0
- package/docs/api/service.md +744 -0
- package/docs/core-concepts/archetypes.md +512 -0
- package/docs/core-concepts/components.md +498 -0
- package/docs/core-concepts/entity.md +314 -0
- package/docs/core-concepts/hooks.md +683 -0
- package/docs/core-concepts/query.md +588 -0
- package/docs/core-concepts/services.md +647 -0
- package/docs/examples/code-examples.md +425 -0
- package/docs/getting-started.md +337 -0
- package/docs/index.html +97 -0
- package/examples/hooks/README.md +228 -0
- package/examples/hooks/audit-logger.ts +495 -0
- package/gql/Generator.ts +56 -34
- package/gql/decorators/Upload.ts +176 -0
- package/gql/helpers.ts +67 -0
- package/gql/index.ts +55 -31
- package/gql/types.ts +1 -1
- package/index.ts +79 -11
- package/package.json +5 -4
- package/rest/Generator.ts +3 -0
- package/rest/index.ts +22 -0
- package/service/Service.ts +1 -1
- package/service/ServiceRegistry.ts +10 -6
- package/service/index.ts +12 -1
- package/tests/bench/insert.bench.ts +59 -0
- package/tests/bench/relations.bench.ts +269 -0
- package/tests/bench/sorting.bench.ts +415 -0
- package/tests/component-hooks.test.ts +1409 -0
- package/tests/component.test.ts +205 -0
- package/tests/errorHandling.test.ts +155 -0
- package/tests/hooks.test.ts +666 -0
- package/tests/query-sorting.test.ts +101 -0
- package/tests/relations.test.ts +169 -0
- package/tests/scheduler.test.ts +724 -0
- package/tsconfig.json +35 -34
- package/types/graphql.types.ts +87 -0
- package/types/hooks.types.ts +141 -0
- package/types/scheduler.types.ts +165 -0
- package/types/upload.types.ts +184 -0
- package/upload/index.ts +140 -0
- package/utils/UploadHelper.ts +305 -0
- package/utils/cronParser.ts +366 -0
- package/utils/errorMessages.ts +151 -0
- package/validate-docs.sh +90 -0
- package/core/Events.ts +0 -0
package/core/EntityManager.ts
CHANGED
|
@@ -21,10 +21,20 @@ class EntityManager {
|
|
|
21
21
|
this.entityQueue.push(entity);
|
|
22
22
|
return resolve(true);
|
|
23
23
|
} else {
|
|
24
|
-
|
|
24
|
+
const result = await entity.doSave();
|
|
25
|
+
resolve(result);
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public deleteEntity(entity: Entity, force: boolean = false) {
|
|
31
|
+
return new Promise<boolean>(async resolve => {
|
|
32
|
+
if(!this.dbReady) {
|
|
33
|
+
return resolve(false);
|
|
34
|
+
} else {
|
|
35
|
+
resolve(entity.doDelete(force));
|
|
25
36
|
}
|
|
26
37
|
})
|
|
27
|
-
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
private async savePendingEntities() {
|
package/core/ErrorHandler.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { GraphQLError, type GraphQLErrorOptions } from "graphql";
|
|
3
|
+
import { logger } from "./Logger";
|
|
4
|
+
import { getErrorMessage, mapZodPathToErrorCode, type ErrorMessage } from "../utils/errorMessages";
|
|
3
5
|
|
|
4
6
|
export function responseError(message: string, extensions?: GraphQLErrorOptions) {
|
|
5
7
|
return new GraphQLError(message, {
|
|
@@ -10,25 +12,80 @@ export function responseError(message: string, extensions?: GraphQLErrorOptions)
|
|
|
10
12
|
});
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Create a user-friendly error response
|
|
17
|
+
*/
|
|
18
|
+
export function createUserFriendlyError(code: string, customMessage?: string, extensions?: GraphQLErrorOptions) {
|
|
19
|
+
const errorInfo = getErrorMessage(code);
|
|
20
|
+
|
|
21
|
+
const baseExtensions = {
|
|
22
|
+
code,
|
|
23
|
+
category: errorInfo.category,
|
|
24
|
+
suggestion: errorInfo.suggestion,
|
|
25
|
+
userFriendly: true
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return new GraphQLError(customMessage || errorInfo.userMessage, {
|
|
29
|
+
extensions: {
|
|
30
|
+
...baseExtensions,
|
|
31
|
+
...extensions?.extensions
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
export function handleGraphQLError(err: any): never {
|
|
14
37
|
if (err instanceof z.ZodError) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
38
|
+
// Convert Zod errors to user-friendly messages
|
|
39
|
+
const userFriendlyErrors = err.issues.map((issue: any) => {
|
|
40
|
+
const errorCode = mapZodPathToErrorCode(issue.path);
|
|
41
|
+
const errorInfo = getErrorMessage(errorCode);
|
|
42
|
+
return {
|
|
43
|
+
field: issue.path.join('.'),
|
|
44
|
+
message: errorInfo.userMessage,
|
|
45
|
+
suggestion: errorInfo.suggestion,
|
|
46
|
+
code: errorCode
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (userFriendlyErrors.length === 0) {
|
|
51
|
+
throw new GraphQLError("Validation failed", {
|
|
52
|
+
extensions: {
|
|
53
|
+
code: "VALIDATION_ERROR",
|
|
54
|
+
category: "validation",
|
|
55
|
+
userFriendly: true
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const primaryError = userFriendlyErrors[0]!;
|
|
61
|
+
const errorMessage = userFriendlyErrors.length === 1
|
|
62
|
+
? primaryError.message
|
|
63
|
+
: `${primaryError.message} (and ${userFriendlyErrors.length - 1} other validation issue${userFriendlyErrors.length > 2 ? 's' : ''})`;
|
|
64
|
+
|
|
65
|
+
throw new GraphQLError(errorMessage, {
|
|
20
66
|
extensions: {
|
|
21
67
|
code: "VALIDATION_ERROR",
|
|
22
|
-
|
|
68
|
+
category: "validation",
|
|
69
|
+
validationErrors: userFriendlyErrors,
|
|
70
|
+
suggestion: primaryError.suggestion,
|
|
71
|
+
userFriendly: true
|
|
23
72
|
}
|
|
24
73
|
});
|
|
25
74
|
}
|
|
26
75
|
if (err instanceof GraphQLError) {
|
|
27
76
|
throw err;
|
|
28
77
|
}
|
|
29
|
-
|
|
78
|
+
logger.error("Unknown error in handleGraphQLError:");
|
|
79
|
+
logger.error(err);
|
|
80
|
+
|
|
81
|
+
const errorInfo = getErrorMessage("INTERNAL_ERROR");
|
|
82
|
+
throw new GraphQLError(errorInfo.userMessage, {
|
|
30
83
|
extensions: {
|
|
31
84
|
code: "INTERNAL_ERROR",
|
|
85
|
+
category: errorInfo.category,
|
|
86
|
+
suggestion: errorInfo.suggestion,
|
|
87
|
+
userFriendly: true,
|
|
88
|
+
node_env: process.env.NODE_ENV,
|
|
32
89
|
originalError: process.env.NODE_ENV === 'development' ? err : undefined
|
|
33
90
|
}
|
|
34
91
|
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import type { UploadConfiguration, ValidationResult } from "../types/upload.types";
|
|
2
|
+
import { logger as MainLogger } from "./Logger";
|
|
3
|
+
|
|
4
|
+
const logger = MainLogger.child({ scope: "FileValidator" });
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File Validator - Comprehensive file validation and security checking
|
|
8
|
+
*/
|
|
9
|
+
export class FileValidator {
|
|
10
|
+
private fileSignatures: Map<string, Uint8Array[]>;
|
|
11
|
+
|
|
12
|
+
constructor() {
|
|
13
|
+
this.fileSignatures = this.initializeFileSignatures();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validate a file against configuration rules
|
|
18
|
+
*/
|
|
19
|
+
public async validate(file: File, config: UploadConfiguration): Promise<ValidationResult> {
|
|
20
|
+
const errors: string[] = [];
|
|
21
|
+
const warnings: string[] = [];
|
|
22
|
+
|
|
23
|
+
logger.trace(`Validating file: ${file.name} (${file.size} bytes, ${file.type})`);
|
|
24
|
+
|
|
25
|
+
// File size validation
|
|
26
|
+
if (file.size > config.maxFileSize) {
|
|
27
|
+
errors.push(`File size ${file.size} exceeds maximum allowed size ${config.maxFileSize}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (file.size === 0) {
|
|
31
|
+
errors.push("File is empty");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// MIME type validation
|
|
35
|
+
if (config.allowedMimeTypes.length > 0) {
|
|
36
|
+
if (!config.allowedMimeTypes.includes(file.type)) {
|
|
37
|
+
errors.push(`MIME type "${file.type}" is not allowed. Allowed types: ${config.allowedMimeTypes.join(", ")}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// File extension validation
|
|
42
|
+
if (config.allowedExtensions.length > 0) {
|
|
43
|
+
const extension = this.getFileExtension(file.name);
|
|
44
|
+
if (!config.allowedExtensions.includes(extension)) {
|
|
45
|
+
errors.push(`File extension "${extension}" is not allowed. Allowed extensions: ${config.allowedExtensions.join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// File signature validation (magic numbers)
|
|
50
|
+
if (config.validateFileSignature) {
|
|
51
|
+
try {
|
|
52
|
+
const signatureValid = await this.validateFileSignature(file);
|
|
53
|
+
if (!signatureValid) {
|
|
54
|
+
errors.push("File signature does not match MIME type (possible file type spoofing)");
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
warnings.push(`Could not validate file signature: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// File name validation
|
|
62
|
+
const nameValidation = this.validateFileName(file.name);
|
|
63
|
+
if (!nameValidation.valid) {
|
|
64
|
+
errors.push(...nameValidation.errors);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Custom validation
|
|
68
|
+
if (config.validation?.customValidators) {
|
|
69
|
+
for (const validator of config.validation.customValidators) {
|
|
70
|
+
try {
|
|
71
|
+
const result = await validator(file);
|
|
72
|
+
if (!result.valid) {
|
|
73
|
+
errors.push(...result.errors);
|
|
74
|
+
}
|
|
75
|
+
if (result.warnings) {
|
|
76
|
+
warnings.push(...result.warnings);
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
warnings.push(`Custom validator failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const valid = errors.length === 0;
|
|
85
|
+
|
|
86
|
+
if (!valid) {
|
|
87
|
+
logger.warn(`File validation failed for ${file.name}: ${errors.join(', ')}`);
|
|
88
|
+
} else {
|
|
89
|
+
logger.trace(`File validation passed for ${file.name}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
valid,
|
|
94
|
+
errors,
|
|
95
|
+
warnings: warnings.length > 0 ? warnings : undefined
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate file name for security issues
|
|
101
|
+
*/
|
|
102
|
+
public validateFileName(fileName: string): ValidationResult {
|
|
103
|
+
const errors: string[] = [];
|
|
104
|
+
|
|
105
|
+
// Check for path traversal attempts
|
|
106
|
+
if (fileName.includes("..") || fileName.includes("/") || fileName.includes("\\")) {
|
|
107
|
+
errors.push("File name contains invalid path characters");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check for dangerous characters
|
|
111
|
+
const dangerousChars = /[<>:"|?*\x00-\x1f]/;
|
|
112
|
+
if (dangerousChars.test(fileName)) {
|
|
113
|
+
errors.push("File name contains dangerous characters");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check for reserved names (Windows)
|
|
117
|
+
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
|
|
118
|
+
if (reservedNames.test(fileName)) {
|
|
119
|
+
errors.push("File name is a reserved system name");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check for hidden files
|
|
123
|
+
if (fileName.startsWith(".")) {
|
|
124
|
+
errors.push("Hidden files are not allowed");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check file name length
|
|
128
|
+
if (fileName.length > 255) {
|
|
129
|
+
errors.push("File name is too long (maximum 255 characters)");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (fileName.length === 0) {
|
|
133
|
+
errors.push("File name cannot be empty");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
valid: errors.length === 0,
|
|
138
|
+
errors
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validate file signature against MIME type
|
|
144
|
+
*/
|
|
145
|
+
private async validateFileSignature(file: File): Promise<boolean> {
|
|
146
|
+
const buffer = await file.slice(0, 32).arrayBuffer();
|
|
147
|
+
const bytes = new Uint8Array(buffer);
|
|
148
|
+
|
|
149
|
+
const expectedSignatures = this.getSignaturesForMimeType(file.type);
|
|
150
|
+
if (expectedSignatures.length === 0) {
|
|
151
|
+
// No known signatures for this MIME type
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return expectedSignatures.some(signature =>
|
|
156
|
+
this.bytesMatch(bytes, signature)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check if bytes match a signature
|
|
162
|
+
*/
|
|
163
|
+
private bytesMatch(bytes: Uint8Array, signature: Uint8Array): boolean {
|
|
164
|
+
if (bytes.length < signature.length) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < signature.length; i++) {
|
|
169
|
+
if (bytes[i] !== signature[i]) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get file signatures for MIME type
|
|
179
|
+
*/
|
|
180
|
+
private getSignaturesForMimeType(mimeType: string): Uint8Array[] {
|
|
181
|
+
return this.fileSignatures.get(mimeType) || [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Extract file extension from filename
|
|
186
|
+
*/
|
|
187
|
+
private getFileExtension(fileName: string): string {
|
|
188
|
+
const lastDot = fileName.lastIndexOf('.');
|
|
189
|
+
return lastDot > 0 ? fileName.slice(lastDot).toLowerCase() : '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Initialize known file signatures (magic numbers)
|
|
194
|
+
*/
|
|
195
|
+
private initializeFileSignatures(): Map<string, Uint8Array[]> {
|
|
196
|
+
const signatures = new Map<string, Uint8Array[]>();
|
|
197
|
+
|
|
198
|
+
// Image formats
|
|
199
|
+
signatures.set("image/jpeg", [
|
|
200
|
+
new Uint8Array([0xFF, 0xD8, 0xFF])
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
signatures.set("image/png", [
|
|
204
|
+
new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
signatures.set("image/gif", [
|
|
208
|
+
new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]), // GIF87a
|
|
209
|
+
new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) // GIF89a
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
signatures.set("image/webp", [
|
|
213
|
+
new Uint8Array([0x52, 0x49, 0x46, 0x46]) // RIFF (WebP is RIFF-based)
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
// Document formats
|
|
217
|
+
signatures.set("application/pdf", [
|
|
218
|
+
new Uint8Array([0x25, 0x50, 0x44, 0x46]) // %PDF
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
// Archive formats
|
|
222
|
+
signatures.set("application/zip", [
|
|
223
|
+
new Uint8Array([0x50, 0x4B, 0x03, 0x04]), // ZIP
|
|
224
|
+
new Uint8Array([0x50, 0x4B, 0x05, 0x06]), // Empty ZIP
|
|
225
|
+
new Uint8Array([0x50, 0x4B, 0x07, 0x08]) // Spanned ZIP
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
return signatures;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get human-readable validation summary
|
|
233
|
+
*/
|
|
234
|
+
public getValidationSummary(result: ValidationResult): string {
|
|
235
|
+
if (result.valid) {
|
|
236
|
+
return "File validation passed";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let summary = `File validation failed: ${result.errors.join("; ")}`;
|
|
240
|
+
|
|
241
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
242
|
+
summary += ` | Warnings: ${result.warnings.join("; ")}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return summary;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Check if file is potentially dangerous
|
|
250
|
+
*/
|
|
251
|
+
public async isDangerous(file: File): Promise<boolean> {
|
|
252
|
+
// Check for executable file extensions
|
|
253
|
+
const dangerousExtensions = [
|
|
254
|
+
'.exe', '.scr', '.bat', '.cmd', '.com', '.pif', '.vbs', '.js', '.jar',
|
|
255
|
+
'.sh', '.py', '.pl', '.php', '.asp', '.aspx', '.jsp'
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const extension = this.getFileExtension(file.name);
|
|
259
|
+
if (dangerousExtensions.includes(extension)) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Check for polyglot files (files that are valid in multiple formats)
|
|
264
|
+
try {
|
|
265
|
+
const buffer = await file.slice(0, 1024).arrayBuffer();
|
|
266
|
+
const bytes = new Uint8Array(buffer);
|
|
267
|
+
const content = new TextDecoder().decode(bytes);
|
|
268
|
+
|
|
269
|
+
// Look for script patterns
|
|
270
|
+
const scriptPatterns = [
|
|
271
|
+
/<script/i,
|
|
272
|
+
/javascript:/i,
|
|
273
|
+
/vbscript:/i,
|
|
274
|
+
/<iframe/i,
|
|
275
|
+
/<object/i,
|
|
276
|
+
/<embed/i
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
return scriptPatterns.some(pattern => pattern.test(content));
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|