@usequark/quark-core 2.5.4
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/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
AppError,
|
|
5
|
+
ConflictError,
|
|
6
|
+
DatabaseError,
|
|
7
|
+
ForbiddenError,
|
|
8
|
+
getErrorMessage,
|
|
9
|
+
getStatusCode,
|
|
10
|
+
logError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
normalizeError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ServiceError,
|
|
15
|
+
UnauthorizedError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
} from "../src/errors.js";
|
|
18
|
+
|
|
19
|
+
test("Error Module", async (t) => {
|
|
20
|
+
await t.test("AppError has correct properties", () => {
|
|
21
|
+
const error = new AppError("Test error", 500, "TEST_ERROR");
|
|
22
|
+
assert(error.message === "Test error");
|
|
23
|
+
assert(error.statusCode === 500);
|
|
24
|
+
assert(error.code === "TEST_ERROR");
|
|
25
|
+
assert(error.name === "AppError");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await t.test("AppError.toJSON() serializes correctly", () => {
|
|
29
|
+
const error = new AppError("Test", 500, "TEST");
|
|
30
|
+
const json = error.toJSON();
|
|
31
|
+
assert(json.message === "Test");
|
|
32
|
+
assert(json.statusCode === 500);
|
|
33
|
+
assert(json.code === "TEST");
|
|
34
|
+
assert(json.timestamp);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await t.test("ValidationError has status 400", () => {
|
|
38
|
+
const error = new ValidationError("Invalid input");
|
|
39
|
+
assert(error.statusCode === 400);
|
|
40
|
+
assert(error.code === "VALIDATION_ERROR");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await t.test("UnauthorizedError has status 401", () => {
|
|
44
|
+
const error = new UnauthorizedError();
|
|
45
|
+
assert(error.statusCode === 401);
|
|
46
|
+
assert(error.code === "UNAUTHORIZED");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await t.test("ForbiddenError has status 403", () => {
|
|
50
|
+
const error = new ForbiddenError();
|
|
51
|
+
assert(error.statusCode === 403);
|
|
52
|
+
assert(error.code === "FORBIDDEN");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await t.test("NotFoundError has status 404", () => {
|
|
56
|
+
const error = new NotFoundError();
|
|
57
|
+
assert(error.statusCode === 404);
|
|
58
|
+
assert(error.code === "NOT_FOUND");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await t.test("ConflictError has status 409", () => {
|
|
62
|
+
const error = new ConflictError();
|
|
63
|
+
assert(error.statusCode === 409);
|
|
64
|
+
assert(error.code === "CONFLICT");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await t.test("RateLimitError has status 429", () => {
|
|
68
|
+
const error = new RateLimitError("Too many requests", 60);
|
|
69
|
+
assert(error.statusCode === 429);
|
|
70
|
+
assert(error.code === "RATE_LIMIT");
|
|
71
|
+
assert(error.retryAfter === 60);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await t.test("DatabaseError wraps database errors", () => {
|
|
75
|
+
const originalError = new Error("Connection failed");
|
|
76
|
+
const error = new DatabaseError("DB error", originalError);
|
|
77
|
+
assert(error.statusCode === 500);
|
|
78
|
+
assert(error.originalError === originalError);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await t.test("ServiceError includes service name", () => {
|
|
82
|
+
const error = new ServiceError("PaymentAPI", "Payment failed");
|
|
83
|
+
assert(error.serviceName === "PaymentAPI");
|
|
84
|
+
assert(error.statusCode === 502);
|
|
85
|
+
const json = error.toJSON();
|
|
86
|
+
assert(json.serviceName === "PaymentAPI");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await t.test("getErrorMessage extracts from various types", () => {
|
|
90
|
+
assert(getErrorMessage("string error") === "string error");
|
|
91
|
+
assert(getErrorMessage(new Error("error message")) === "error message");
|
|
92
|
+
assert(getErrorMessage({ message: "obj error" }) === "obj error");
|
|
93
|
+
assert(getErrorMessage({}).includes("unknown")); // Changed from null to empty object
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await t.test("getStatusCode returns correct code", () => {
|
|
97
|
+
assert(getStatusCode(new ValidationError("")) === 400);
|
|
98
|
+
assert(getStatusCode(new UnauthorizedError()) === 401);
|
|
99
|
+
assert(getStatusCode(new NotFoundError()) === 404);
|
|
100
|
+
assert(getStatusCode(new Error()) === 500);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await t.test("normalizeError converts to AppError", () => {
|
|
104
|
+
const error = normalizeError(new Error("test"));
|
|
105
|
+
assert(error instanceof AppError);
|
|
106
|
+
assert(error.message === "test");
|
|
107
|
+
assert(error.statusCode === 500);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await t.test("normalizeError handles string errors", () => {
|
|
111
|
+
const error = normalizeError("string error");
|
|
112
|
+
assert(error instanceof AppError);
|
|
113
|
+
assert(error.message === "string error");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t.test("normalizeError handles non-Error objects", () => {
|
|
117
|
+
const error = normalizeError({ some: "object" });
|
|
118
|
+
assert(error instanceof AppError);
|
|
119
|
+
assert(error.message.includes("unknown"));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await t.test("logError handles errors with context", () => {
|
|
123
|
+
// This should not throw
|
|
124
|
+
const error = new ValidationError("test");
|
|
125
|
+
logError(error, { userId: "123", action: "test" });
|
|
126
|
+
assert(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
import * as core from "@techstream/quark-core";
|
|
6
|
+
import * as admin from "@techstream/quark-core/admin";
|
|
7
|
+
import * as auth from "@techstream/quark-core/auth";
|
|
8
|
+
import * as authMiddleware from "@techstream/quark-core/auth/middleware";
|
|
9
|
+
import * as coreLight from "@techstream/quark-core/core";
|
|
10
|
+
import * as db from "@techstream/quark-core/db";
|
|
11
|
+
import * as email from "@techstream/quark-core/email";
|
|
12
|
+
import * as errors from "@techstream/quark-core/errors";
|
|
13
|
+
import * as locale from "@techstream/quark-core/locale";
|
|
14
|
+
import * as logger from "@techstream/quark-core/logger";
|
|
15
|
+
import * as metrics from "@techstream/quark-core/metrics";
|
|
16
|
+
import * as queue from "@techstream/quark-core/queue";
|
|
17
|
+
import * as sms from "@techstream/quark-core/sms";
|
|
18
|
+
import * as storage from "@techstream/quark-core/storage";
|
|
19
|
+
import * as storageS3 from "@techstream/quark-core/storage/s3";
|
|
20
|
+
import * as stripe from "@techstream/quark-core/stripe";
|
|
21
|
+
import * as testing from "@techstream/quark-core/testing";
|
|
22
|
+
|
|
23
|
+
const packageJson = JSON.parse(
|
|
24
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Guard against accidental subpath removals: every entry in the exports map
|
|
28
|
+
// must resolve. apps/web imports `@techstream/quark-core/logger`, and the
|
|
29
|
+
// scaffolded i18n/payment skills document `./locale` and `./stripe`.
|
|
30
|
+
test("every subpath in package.json exports resolves", async () => {
|
|
31
|
+
for (const subpath of Object.keys(packageJson.exports)) {
|
|
32
|
+
const specifier = `@techstream/quark-core${
|
|
33
|
+
subpath === "." ? "" : subpath.slice(1)
|
|
34
|
+
}`;
|
|
35
|
+
await assert.doesNotReject(
|
|
36
|
+
() => import(specifier),
|
|
37
|
+
`failed to import ${specifier}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("subpath '.' (full barrel) exports all key utilities", () => {
|
|
43
|
+
assert.equal(typeof core.createAuthConfig, "function");
|
|
44
|
+
assert.equal(typeof core.createStorage, "function");
|
|
45
|
+
assert.equal(typeof core.AppError, "function");
|
|
46
|
+
assert.equal(typeof core.createLogger, "function");
|
|
47
|
+
assert.equal(typeof core.createS3Storage, "function");
|
|
48
|
+
assert.equal(typeof core.createPrismaClient, "function");
|
|
49
|
+
assert.equal(typeof core.pingDatabase, "function");
|
|
50
|
+
assert.equal(typeof core.parseSchema, "function");
|
|
51
|
+
assert.equal(typeof core.requireSession, "function");
|
|
52
|
+
assert.equal(typeof core.requireSessionRole, "function");
|
|
53
|
+
assert.equal(typeof core.createAuthMiddleware, "function");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("subpath './core' exports lightweight utilities", () => {
|
|
57
|
+
assert.equal(typeof coreLight.AppError, "function");
|
|
58
|
+
assert.equal(typeof coreLight.ValidationError, "function");
|
|
59
|
+
assert.equal(typeof coreLight.createLogger, "function");
|
|
60
|
+
assert.equal(typeof coreLight.validateBody, "function");
|
|
61
|
+
assert.equal(typeof coreLight.retryAsync, "function");
|
|
62
|
+
assert.equal(typeof coreLight.parsePagination, "function");
|
|
63
|
+
assert.equal(typeof coreLight.generateCsrfToken, "function");
|
|
64
|
+
assert.equal(typeof coreLight.createRateLimiter, "function");
|
|
65
|
+
assert.equal(typeof coreLight.validateFile, "function");
|
|
66
|
+
assert.equal(typeof coreLight.createPrismaClient, "function");
|
|
67
|
+
assert.equal(typeof coreLight.pingDatabase, "function");
|
|
68
|
+
// Should NOT export heavier modules
|
|
69
|
+
assert.equal(coreLight.createStorage, undefined);
|
|
70
|
+
assert.equal(coreLight.createEmailService, undefined);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("subpath './auth' exports config + middleware", () => {
|
|
74
|
+
assert.equal(typeof auth.createAuthConfig, "function");
|
|
75
|
+
assert.equal(typeof auth.requireSession, "function");
|
|
76
|
+
assert.equal(typeof auth.requireSessionRole, "function");
|
|
77
|
+
assert.equal(typeof auth.createAuthMiddleware, "function");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("subpath './auth/middleware' exports middleware only", () => {
|
|
81
|
+
assert.equal(typeof authMiddleware.requireSession, "function");
|
|
82
|
+
assert.equal(typeof authMiddleware.requireSessionRole, "function");
|
|
83
|
+
assert.equal(typeof authMiddleware.createAuthMiddleware, "function");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("subpath './errors' exports error classes", () => {
|
|
87
|
+
assert.equal(typeof errors.AppError, "function");
|
|
88
|
+
assert.equal(typeof errors.ValidationError, "function");
|
|
89
|
+
assert.equal(typeof errors.UnauthorizedError, "function");
|
|
90
|
+
assert.equal(typeof errors.ForbiddenError, "function");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("subpath './logger' exports the browser-safe logger", () => {
|
|
94
|
+
assert.equal(typeof logger.createLogger, "function");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("subpath './locale' exports locale utilities", () => {
|
|
98
|
+
assert.equal(typeof locale.getDefaultLocale, "function");
|
|
99
|
+
assert.equal(typeof locale.getSupportedLocales, "function");
|
|
100
|
+
assert.equal(typeof locale.isLocaleSupported, "function");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("subpath './stripe' exports payment utilities", () => {
|
|
104
|
+
assert.equal(typeof stripe.createStripeClient, "function");
|
|
105
|
+
assert.equal(typeof stripe.getStripeWebhookEvent, "function");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("subpath './storage' exports storage utilities", () => {
|
|
109
|
+
assert.equal(typeof storage.createStorage, "function");
|
|
110
|
+
assert.equal(typeof storage.createLocalStorage, "function");
|
|
111
|
+
assert.equal(typeof storage.createS3Storage, "function");
|
|
112
|
+
assert.equal(typeof storage.generateStorageKey, "function");
|
|
113
|
+
assert.equal(typeof storage.getAssetUrl, "function");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("subpath './storage/s3' exports S3 adapter only", () => {
|
|
117
|
+
assert.equal(typeof storageS3.createS3Storage, "function");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("subpath './db' exports database utilities", () => {
|
|
121
|
+
assert.equal(typeof db.getConnectionString, "function");
|
|
122
|
+
assert.equal(typeof db.getPoolConfig, "function");
|
|
123
|
+
assert.equal(typeof db.createPrismaClient, "function");
|
|
124
|
+
assert.equal(typeof db.pingDatabase, "function");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("subpath './admin' exports introspection utilities", () => {
|
|
128
|
+
assert.equal(typeof admin.parseSchema, "function");
|
|
129
|
+
assert.equal(typeof admin.getModelByName, "function");
|
|
130
|
+
assert.equal(typeof admin.detectIdField, "function");
|
|
131
|
+
assert.equal(typeof admin.coerceId, "function");
|
|
132
|
+
assert.equal(typeof admin.getModels, "function");
|
|
133
|
+
assert.equal(typeof admin.getEnums, "function");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("subpath './queue' exports queue utilities", () => {
|
|
137
|
+
assert.equal(typeof queue.createQueue, "function");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("subpath './email' exports email utilities", () => {
|
|
141
|
+
assert.equal(typeof email.createEmailService, "function");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("subpath './sms' exports SMS utilities", () => {
|
|
145
|
+
assert.equal(typeof sms.createSmsService, "function");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("subpath './metrics' exports metrics utilities", () => {
|
|
149
|
+
assert.equal(typeof metrics.createMetrics, "function");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("subpath './testing' exports test utilities", () => {
|
|
153
|
+
assert.equal(typeof testing.createTestUser, "function");
|
|
154
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - File Validation
|
|
3
|
+
*
|
|
4
|
+
* Validates uploaded files: size limits, MIME type allow-lists,
|
|
5
|
+
* and magic-byte verification to prevent MIME spoofing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Magic bytes for common file types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const MAGIC_BYTES = [
|
|
13
|
+
// Images
|
|
14
|
+
{ mime: "image/jpeg", bytes: [0xff, 0xd8, 0xff] },
|
|
15
|
+
{ mime: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47] },
|
|
16
|
+
{ mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] },
|
|
17
|
+
{
|
|
18
|
+
mime: "image/webp",
|
|
19
|
+
bytes: [0x52, 0x49, 0x46, 0x46],
|
|
20
|
+
offset: 0,
|
|
21
|
+
extra: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
mime: "image/svg+xml",
|
|
25
|
+
bytes: null,
|
|
26
|
+
detect: (buf) => {
|
|
27
|
+
const head = buf.subarray(0, 256).toString("utf8").trimStart();
|
|
28
|
+
return head.startsWith("<svg") || head.startsWith("<?xml");
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
mime: "image/avif",
|
|
33
|
+
bytes: null,
|
|
34
|
+
detect: (buf) => {
|
|
35
|
+
// ftyp box with "avif" or "avis" brand
|
|
36
|
+
if (buf.length < 12) return false;
|
|
37
|
+
const ftyp = buf.subarray(4, 8).toString("ascii");
|
|
38
|
+
if (ftyp !== "ftyp") return false;
|
|
39
|
+
const brand = buf.subarray(8, 12).toString("ascii");
|
|
40
|
+
return brand === "avif" || brand === "avis";
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Documents
|
|
45
|
+
{ mime: "application/pdf", bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF
|
|
46
|
+
{ mime: "application/zip", bytes: [0x50, 0x4b, 0x03, 0x04] },
|
|
47
|
+
|
|
48
|
+
// Video - MP4 uses ftyp box like AVIF, but with different brands (isom, mp41, mp42, etc.)
|
|
49
|
+
{
|
|
50
|
+
mime: "video/mp4",
|
|
51
|
+
bytes: null,
|
|
52
|
+
detect: (buf) => {
|
|
53
|
+
if (buf.length < 12) return false;
|
|
54
|
+
const ftyp = buf.subarray(4, 8).toString("ascii");
|
|
55
|
+
if (ftyp !== "ftyp") return false;
|
|
56
|
+
// Exclude AVIF brands already handled above
|
|
57
|
+
const brand = buf.subarray(8, 12).toString("ascii");
|
|
58
|
+
if (brand === "avif" || brand === "avis") return false;
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{ mime: "video/webm", bytes: [0x1a, 0x45, 0xdf, 0xa3] },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Detect MIME type from file buffer using magic bytes.
|
|
67
|
+
* Returns null if no match found.
|
|
68
|
+
* @param {Buffer} buffer - At least the first 256 bytes of the file
|
|
69
|
+
* @returns {string | null}
|
|
70
|
+
*/
|
|
71
|
+
export function detectMimeType(buffer) {
|
|
72
|
+
for (const entry of MAGIC_BYTES) {
|
|
73
|
+
if (entry.detect) {
|
|
74
|
+
if (entry.detect(buffer)) return entry.mime;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!entry.bytes) continue;
|
|
79
|
+
|
|
80
|
+
const offset = entry.offset || 0;
|
|
81
|
+
let match = true;
|
|
82
|
+
for (let i = 0; i < entry.bytes.length; i++) {
|
|
83
|
+
if (buffer[offset + i] !== entry.bytes[i]) {
|
|
84
|
+
match = false;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (match && entry.extra) {
|
|
90
|
+
for (let i = 0; i < entry.extra.bytes.length; i++) {
|
|
91
|
+
if (buffer[entry.extra.offset + i] !== entry.extra.bytes[i]) {
|
|
92
|
+
match = false;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (match) return entry.mime;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Defaults
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
const DEFAULT_MAX_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
109
|
+
|
|
110
|
+
const DEFAULT_ALLOWED_TYPES = [
|
|
111
|
+
"image/jpeg",
|
|
112
|
+
"image/png",
|
|
113
|
+
"image/gif",
|
|
114
|
+
"image/webp",
|
|
115
|
+
"image/avif",
|
|
116
|
+
"image/svg+xml",
|
|
117
|
+
"application/pdf",
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Validation
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {Object} FileValidationOptions
|
|
126
|
+
* @property {number} [maxSize] - Max file size in bytes (default: 10MB)
|
|
127
|
+
* @property {string[]} [allowedTypes] - Allowed MIME types. Supports wildcards like "image/*"
|
|
128
|
+
* @property {boolean} [verifyMagicBytes=true] - Check magic bytes match declared MIME type
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @typedef {Object} FileValidationResult
|
|
133
|
+
* @property {boolean} valid
|
|
134
|
+
* @property {string} [error] - Human-readable error message (when invalid)
|
|
135
|
+
* @property {string} [detectedType] - MIME type detected from magic bytes
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Validate a file against size, type and magic-byte rules.
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} file
|
|
142
|
+
* @param {string} file.filename - Original filename
|
|
143
|
+
* @param {string} file.mimeType - Declared MIME type (from Content-Type)
|
|
144
|
+
* @param {number} file.size - File size in bytes
|
|
145
|
+
* @param {Buffer} [file.buffer] - File content (required if verifyMagicBytes is true)
|
|
146
|
+
* @param {FileValidationOptions} [options]
|
|
147
|
+
* @returns {FileValidationResult}
|
|
148
|
+
*/
|
|
149
|
+
export function validateFile(file, options = {}) {
|
|
150
|
+
const {
|
|
151
|
+
maxSize = parseMaxSize(),
|
|
152
|
+
allowedTypes = parseAllowedTypes(),
|
|
153
|
+
verifyMagicBytes = true,
|
|
154
|
+
} = options;
|
|
155
|
+
|
|
156
|
+
// Size check
|
|
157
|
+
if (file.size > maxSize) {
|
|
158
|
+
return {
|
|
159
|
+
valid: false,
|
|
160
|
+
error: `File exceeds maximum size of ${formatBytes(maxSize)} (got ${formatBytes(file.size)})`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// MIME type allow-list
|
|
165
|
+
if (!isTypeAllowed(file.mimeType, allowedTypes)) {
|
|
166
|
+
return {
|
|
167
|
+
valid: false,
|
|
168
|
+
error: `File type "${file.mimeType}" is not allowed. Allowed: ${allowedTypes.join(", ")}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Magic byte verification
|
|
173
|
+
if (verifyMagicBytes && file.buffer) {
|
|
174
|
+
const detected = detectMimeType(file.buffer);
|
|
175
|
+
if (detected && !isSameCategory(detected, file.mimeType)) {
|
|
176
|
+
return {
|
|
177
|
+
valid: false,
|
|
178
|
+
error: `File content does not match declared type. Declared: ${file.mimeType}, detected: ${detected}`,
|
|
179
|
+
detectedType: detected,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return { valid: true, detectedType: detected };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { valid: true };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if a MIME type matches an allow list (supports wildcards like "image/*").
|
|
190
|
+
* @param {string} mimeType
|
|
191
|
+
* @param {string[]} allowedTypes
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
export function isTypeAllowed(mimeType, allowedTypes) {
|
|
195
|
+
if (!mimeType) return false;
|
|
196
|
+
const normalized = mimeType.toLowerCase().trim();
|
|
197
|
+
|
|
198
|
+
for (const pattern of allowedTypes) {
|
|
199
|
+
const p = pattern.toLowerCase().trim();
|
|
200
|
+
if (p === normalized) return true;
|
|
201
|
+
if (p === "*/*") return true;
|
|
202
|
+
if (p.endsWith("/*")) {
|
|
203
|
+
const category = p.slice(0, p.indexOf("/"));
|
|
204
|
+
if (normalized.startsWith(`${category}/`)) return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Helpers
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
function parseMaxSize() {
|
|
216
|
+
const env = process.env.UPLOAD_MAX_SIZE;
|
|
217
|
+
if (!env) return DEFAULT_MAX_SIZE;
|
|
218
|
+
const n = Number(env);
|
|
219
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_SIZE;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseAllowedTypes() {
|
|
223
|
+
const env = process.env.UPLOAD_ALLOWED_TYPES;
|
|
224
|
+
if (!env) return DEFAULT_ALLOWED_TYPES;
|
|
225
|
+
return env
|
|
226
|
+
.split(",")
|
|
227
|
+
.map((t) => t.trim())
|
|
228
|
+
.filter(Boolean);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function formatBytes(bytes) {
|
|
232
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
233
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
234
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if two MIME types belong to the same top-level category.
|
|
239
|
+
* e.g. "image/jpeg" and "image/png" → true
|
|
240
|
+
* @param {string} a
|
|
241
|
+
* @param {string} b
|
|
242
|
+
* @returns {boolean}
|
|
243
|
+
*/
|
|
244
|
+
function isSameCategory(a, b) {
|
|
245
|
+
if (!a || !b) return true; // lenient when one side is unknown
|
|
246
|
+
const catA = a.split("/")[0];
|
|
247
|
+
const catB = b.split("/")[0];
|
|
248
|
+
return catA === catB;
|
|
249
|
+
}
|