bunigniter 0.2.0
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/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/LICENSE +21 -0
- package/dist/README.md +229 -0
- package/dist/base/controller.ts +324 -0
- package/dist/base/index.ts +5 -0
- package/dist/base/service.ts +21 -0
- package/dist/cli/index.ts +318 -0
- package/dist/cli/list-routes.ts +72 -0
- package/dist/cli/repl.ts +461 -0
- package/dist/cli/templates.ts +283 -0
- package/dist/client/index.ts +159 -0
- package/dist/db/drizzle.ts +550 -0
- package/dist/db/validators.ts +229 -0
- package/dist/edge-builder.ts +120 -0
- package/dist/edge.ts +69 -0
- package/dist/helpers/cache.ts +173 -0
- package/dist/helpers/cors.ts +103 -0
- package/dist/helpers/csrf.ts +155 -0
- package/dist/helpers/debug.ts +158 -0
- package/dist/helpers/env.ts +147 -0
- package/dist/helpers/handler.ts +158 -0
- package/dist/helpers/http.ts +194 -0
- package/dist/helpers/image.ts +217 -0
- package/dist/helpers/jwt.ts +147 -0
- package/dist/helpers/logger.ts +96 -0
- package/dist/helpers/mail.ts +272 -0
- package/dist/helpers/middleware-loader.ts +116 -0
- package/dist/helpers/middleware.ts +57 -0
- package/dist/helpers/modules.ts +115 -0
- package/dist/helpers/openapi.ts +140 -0
- package/dist/helpers/pagination.ts +159 -0
- package/dist/helpers/queue.ts +186 -0
- package/dist/helpers/request-context.ts +13 -0
- package/dist/helpers/request.ts +376 -0
- package/dist/helpers/schedule.ts +173 -0
- package/dist/helpers/session-middleware.ts +89 -0
- package/dist/helpers/session.ts +286 -0
- package/dist/helpers/sse.ts +90 -0
- package/dist/helpers/throttle.ts +156 -0
- package/dist/helpers/upload.ts +417 -0
- package/dist/helpers/validator.ts +287 -0
- package/dist/helpers/ws.ts +123 -0
- package/dist/index.ts +221 -0
- package/dist/package.json +70 -0
- package/dist/router/file-router.ts +541 -0
- package/dist/router/server-router.ts +103 -0
- package/dist/view/page.ts +96 -0
- package/dist/view/renderer.tsx +390 -0
- package/dist/view/view-response.ts +10 -0
- package/package.json +70 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload — file upload handling with validation.
|
|
3
|
+
*
|
|
4
|
+
* Usage in a Controller:
|
|
5
|
+
* ```ts
|
|
6
|
+
* // Auto-detect body from context (recommended)
|
|
7
|
+
* const file = await this.upload.file('avatar')
|
|
8
|
+
*
|
|
9
|
+
* // Or explicitly pass body
|
|
10
|
+
* const file = this.upload.file(this.body, 'avatar')
|
|
11
|
+
*
|
|
12
|
+
* if (!file) return this.badRequest({ avatar: 'File is required' })
|
|
13
|
+
* if (file.fails()) return this.badRequest({ avatar: file.errors() })
|
|
14
|
+
*
|
|
15
|
+
* const path = await this.upload.store(file, 'avatars')
|
|
16
|
+
* return this.json({ path })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
|
|
20
|
+
import { join, extname } from "node:path";
|
|
21
|
+
import { env } from "./env";
|
|
22
|
+
import crypto from "node:crypto";
|
|
23
|
+
|
|
24
|
+
export interface UploadOptions {
|
|
25
|
+
/** Maximum file size in bytes. Default: 10MB */
|
|
26
|
+
maxSize?: number;
|
|
27
|
+
|
|
28
|
+
/** Allowed MIME types. Default: all */
|
|
29
|
+
allowedMimes?: string[];
|
|
30
|
+
|
|
31
|
+
/** Allowed file extensions (including dot, e.g. '.jpg'). Default: all */
|
|
32
|
+
allowedExts?: string[];
|
|
33
|
+
|
|
34
|
+
/** Storage directory relative to CWD. Default: 'storage' */
|
|
35
|
+
storageDir?: string;
|
|
36
|
+
|
|
37
|
+
/** Max files per request. Default: 10 */
|
|
38
|
+
maxFiles?: number;
|
|
39
|
+
|
|
40
|
+
/** Whether to overwrite existing files. Default: false */
|
|
41
|
+
overwrite?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UploadedFile {
|
|
45
|
+
/** Original form field name. */
|
|
46
|
+
field: string;
|
|
47
|
+
|
|
48
|
+
/** Original file name from the client. */
|
|
49
|
+
name: string;
|
|
50
|
+
|
|
51
|
+
/** File size in bytes. */
|
|
52
|
+
size: number;
|
|
53
|
+
|
|
54
|
+
/** MIME type. */
|
|
55
|
+
type: string;
|
|
56
|
+
|
|
57
|
+
/** Stored path relative to storage dir (set after store()). */
|
|
58
|
+
storedPath?: string;
|
|
59
|
+
|
|
60
|
+
/** Absolute path on disk (set after store()). */
|
|
61
|
+
absolutePath?: string;
|
|
62
|
+
|
|
63
|
+
/** Validation errors. */
|
|
64
|
+
_errors?: string[];
|
|
65
|
+
|
|
66
|
+
/** Internal: reference to the raw File/Blob object. */
|
|
67
|
+
_raw?: any;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Upload service — handle file uploads with validation and storage.
|
|
72
|
+
*/
|
|
73
|
+
export class Upload {
|
|
74
|
+
private options: Required<UploadOptions>;
|
|
75
|
+
private _body: any;
|
|
76
|
+
|
|
77
|
+
constructor(options: UploadOptions = {}) {
|
|
78
|
+
this.options = {
|
|
79
|
+
maxSize: options.maxSize ?? 10 * 1024 * 1024,
|
|
80
|
+
allowedMimes: options.allowedMimes ?? [],
|
|
81
|
+
allowedExts: options.allowedExts ?? [],
|
|
82
|
+
storageDir: options.storageDir ?? env("STORAGE_DIR", "storage"),
|
|
83
|
+
maxFiles: options.maxFiles ?? 10,
|
|
84
|
+
overwrite: options.overwrite ?? false,
|
|
85
|
+
};
|
|
86
|
+
this._body = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Set the request body for auto-detection.
|
|
91
|
+
* Called internally by the framework.
|
|
92
|
+
*/
|
|
93
|
+
set body(body: any) {
|
|
94
|
+
this._body = body;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── File Detection ─────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if a file was uploaded for the given field.
|
|
101
|
+
*
|
|
102
|
+
* @param body - Request body (or omit to auto-detect from controller context)
|
|
103
|
+
* @param field - Form field name
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* if (this.upload.hasFile('avatar')) { ... }
|
|
108
|
+
* if (this.upload.hasFile(this.body, 'avatar')) { ... }
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
hasFile(bodyOrField: any, field?: string): boolean {
|
|
112
|
+
if (field === undefined) {
|
|
113
|
+
field = bodyOrField as string;
|
|
114
|
+
bodyOrField = this._body;
|
|
115
|
+
}
|
|
116
|
+
if (!bodyOrField || typeof bodyOrField !== "object") return false;
|
|
117
|
+
const raw = (bodyOrField as Record<string, any>)[field!];
|
|
118
|
+
if (!raw) return false;
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(raw)) return raw.length > 0 && this._isFile(raw[0]);
|
|
121
|
+
return this._isFile(raw);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── File Retrieval ─────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get a single uploaded file from the request.
|
|
128
|
+
*
|
|
129
|
+
* @param body - Request body, or field name (auto-detect from context)
|
|
130
|
+
* @param field - Form field name (omit if first arg is field name)
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* // Auto-detect body from controller context
|
|
135
|
+
* const file = await this.upload.file('avatar')
|
|
136
|
+
*
|
|
137
|
+
* // Explicit body
|
|
138
|
+
* const file = this.upload.file(this.body, 'avatar')
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
async file(bodyOrField: any, field?: string): Promise<UploadedFile | null> {
|
|
142
|
+
if (field === undefined) {
|
|
143
|
+
field = bodyOrField as string;
|
|
144
|
+
bodyOrField = this._body;
|
|
145
|
+
}
|
|
146
|
+
if (!bodyOrField || typeof bodyOrField !== "object") return null;
|
|
147
|
+
|
|
148
|
+
const raw = (bodyOrField as Record<string, any>)[field!];
|
|
149
|
+
if (!raw) return null;
|
|
150
|
+
|
|
151
|
+
// Handle single file
|
|
152
|
+
if (!Array.isArray(raw)) {
|
|
153
|
+
return this._parseFile(raw, field!);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Array but only one item
|
|
157
|
+
if (raw.length === 1) {
|
|
158
|
+
return this._parseFile(raw[0], field!);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Multiple files — return first, log warning
|
|
162
|
+
if (raw.length > 1) {
|
|
163
|
+
console.warn(
|
|
164
|
+
`[upload] Multiple files for field "${field}", use .files() instead`,
|
|
165
|
+
);
|
|
166
|
+
return this._parseFile(raw[0], field!);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get multiple uploaded files from the request.
|
|
174
|
+
*
|
|
175
|
+
* @param body - Request body, or field name (auto-detect from context)
|
|
176
|
+
* @param field - Form field name (omit if first arg is field name)
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* const files = await this.upload.files('gallery')
|
|
181
|
+
* for (const file of files) {
|
|
182
|
+
* await this.upload.store(file, 'gallery')
|
|
183
|
+
* }
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
async files(bodyOrField: any, field?: string): Promise<UploadedFile[]> {
|
|
187
|
+
if (field === undefined) {
|
|
188
|
+
field = bodyOrField as string;
|
|
189
|
+
bodyOrField = this._body;
|
|
190
|
+
}
|
|
191
|
+
if (!bodyOrField || typeof bodyOrField !== "object") return [];
|
|
192
|
+
|
|
193
|
+
const raw = (bodyOrField as Record<string, any>)[field!];
|
|
194
|
+
if (!raw) return [];
|
|
195
|
+
|
|
196
|
+
const items = Array.isArray(raw) ? raw : [raw];
|
|
197
|
+
const result: UploadedFile[] = [];
|
|
198
|
+
|
|
199
|
+
for (const item of items) {
|
|
200
|
+
if (this._isFile(item)) {
|
|
201
|
+
result.push(await this._parseFile(item, field!));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Validation ─────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Validate a file against configured rules.
|
|
212
|
+
* Returns error messages array, or empty array if valid.
|
|
213
|
+
*/
|
|
214
|
+
validate(file: UploadedFile): string[] {
|
|
215
|
+
const errors: string[] = [];
|
|
216
|
+
|
|
217
|
+
// Size check
|
|
218
|
+
if (file.size > this.options.maxSize) {
|
|
219
|
+
const mb = (this.options.maxSize / (1024 * 1024)).toFixed(1);
|
|
220
|
+
errors.push(`File exceeds maximum size of ${mb}MB`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// MIME check
|
|
224
|
+
if (
|
|
225
|
+
this.options.allowedMimes.length > 0 &&
|
|
226
|
+
!this.options.allowedMimes.includes(file.type)
|
|
227
|
+
) {
|
|
228
|
+
errors.push(`File type "${file.type}" is not allowed`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Extension check
|
|
232
|
+
if (this.options.allowedExts.length > 0) {
|
|
233
|
+
const ext = this.extension(file);
|
|
234
|
+
if (!this.options.allowedExts.includes(ext)) {
|
|
235
|
+
errors.push(
|
|
236
|
+
`File extension "${ext}" is not allowed (allowed: ${this.options.allowedExts.join(", ")})`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return errors;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Get file extension from the original name. */
|
|
245
|
+
extension(file: UploadedFile): string {
|
|
246
|
+
return extname(file.name).toLowerCase();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── Storage ────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Store a file to disk.
|
|
253
|
+
*
|
|
254
|
+
* @param file - Uploaded file
|
|
255
|
+
* @param subdir - Subdirectory within storage dir (e.g. 'avatars')
|
|
256
|
+
* @param filename - Custom filename (without extension). Default: timestamp_random
|
|
257
|
+
* @returns The stored file path relative to storage dir
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ```ts
|
|
261
|
+
* const path = await this.upload.store(file, 'avatars')
|
|
262
|
+
* const path = await this.upload.store(file, 'avatars', 'profile')
|
|
263
|
+
* // → 'avatars/1748200000_a1b2c3d4.jpg'
|
|
264
|
+
* // → 'avatars/profile.jpg'
|
|
265
|
+
* ```
|
|
266
|
+
*/
|
|
267
|
+
async store(
|
|
268
|
+
file: UploadedFile,
|
|
269
|
+
subdir = "",
|
|
270
|
+
filename?: string,
|
|
271
|
+
): Promise<string> {
|
|
272
|
+
const storageDir = join(process.cwd(), this.options.storageDir, subdir);
|
|
273
|
+
if (!existsSync(storageDir)) {
|
|
274
|
+
mkdirSync(storageDir, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const ext = this.extension(file);
|
|
278
|
+
const name = filename
|
|
279
|
+
? `${filename}${ext}`
|
|
280
|
+
: `${Date.now()}_${crypto.randomUUID().slice(0, 8)}${ext}`;
|
|
281
|
+
|
|
282
|
+
const fullPath = join(storageDir, name);
|
|
283
|
+
|
|
284
|
+
// Check existing
|
|
285
|
+
if (existsSync(fullPath) && !this.options.overwrite) {
|
|
286
|
+
throw new Error(`File already exists: ${name}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Write file from various sources
|
|
290
|
+
const buffer = await this._getBuffer(file);
|
|
291
|
+
writeFileSync(fullPath, buffer);
|
|
292
|
+
|
|
293
|
+
// Update file metadata
|
|
294
|
+
file.storedPath = subdir ? `${subdir}/${name}` : name;
|
|
295
|
+
file.absolutePath = fullPath;
|
|
296
|
+
|
|
297
|
+
return file.storedPath;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Delete a stored file from disk.
|
|
302
|
+
*
|
|
303
|
+
* @param path - Relative path from storage dir (e.g. 'avatars/abc.jpg')
|
|
304
|
+
* @returns true if deleted, false if not found
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```ts
|
|
308
|
+
* await this.upload.delete('avatars/old.jpg')
|
|
309
|
+
* ```
|
|
310
|
+
*/
|
|
311
|
+
delete(subpath: string): boolean {
|
|
312
|
+
const fullPath = join(process.cwd(), this.options.storageDir, subpath);
|
|
313
|
+
if (!existsSync(fullPath)) return false;
|
|
314
|
+
unlinkSync(fullPath);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Delete the stored file associated with an UploadedFile.
|
|
320
|
+
*/
|
|
321
|
+
deleteFile(file: UploadedFile): boolean {
|
|
322
|
+
if (!file.absolutePath) return false;
|
|
323
|
+
if (!existsSync(file.absolutePath)) return false;
|
|
324
|
+
unlinkSync(file.absolutePath);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Get the configured max upload size in bytes. */
|
|
329
|
+
get maxSize(): number {
|
|
330
|
+
return this.options.maxSize;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Get the configured storage directory. */
|
|
334
|
+
get storageDir(): string {
|
|
335
|
+
return this.options.storageDir;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ─── Internal Helpers ───────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/** Check if a raw value looks like a file. */
|
|
341
|
+
private _isFile(raw: any): boolean {
|
|
342
|
+
if (!raw) return false;
|
|
343
|
+
return (
|
|
344
|
+
raw instanceof File ||
|
|
345
|
+
raw instanceof Blob ||
|
|
346
|
+
typeof raw?.name === "string"
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Parse a raw file value into an UploadedFile. */
|
|
351
|
+
private async _parseFile(raw: any, field: string): Promise<UploadedFile> {
|
|
352
|
+
const file: UploadedFile = {
|
|
353
|
+
field,
|
|
354
|
+
name: raw.name ?? "unknown",
|
|
355
|
+
size: raw.size ?? 0,
|
|
356
|
+
type: raw.type ?? "application/octet-stream",
|
|
357
|
+
_raw: raw,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Validate
|
|
361
|
+
const errors = this.validate(file);
|
|
362
|
+
if (errors.length > 0) {
|
|
363
|
+
file._errors = errors;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return file;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Extract file buffer from various sources. */
|
|
370
|
+
private async _getBuffer(file: UploadedFile): Promise<Buffer> {
|
|
371
|
+
// Use stored raw reference first
|
|
372
|
+
if (file._raw) {
|
|
373
|
+
if (typeof file._raw.arrayBuffer === "function") {
|
|
374
|
+
const ab = await file._raw.arrayBuffer();
|
|
375
|
+
return Buffer.from(ab);
|
|
376
|
+
}
|
|
377
|
+
if (file._raw instanceof Blob || file._raw instanceof File) {
|
|
378
|
+
const ab = await file._raw.arrayBuffer();
|
|
379
|
+
return Buffer.from(ab);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Fallback: search body for matching File object
|
|
384
|
+
if (this._body) {
|
|
385
|
+
for (const key of Object.keys(this._body)) {
|
|
386
|
+
const raw = this._body[key];
|
|
387
|
+
const items = Array.isArray(raw) ? raw : [raw];
|
|
388
|
+
for (const item of items) {
|
|
389
|
+
if (typeof item?.arrayBuffer === "function") {
|
|
390
|
+
try {
|
|
391
|
+
const ab = await item.arrayBuffer();
|
|
392
|
+
return Buffer.from(ab);
|
|
393
|
+
} catch {
|
|
394
|
+
// continue searching
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Cannot read file buffer for "${file.name}". Ensure multipart form data is properly parsed.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let _uploadInstance: Upload | null = null;
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Create or retrieve the global Upload instance.
|
|
411
|
+
*/
|
|
412
|
+
export function createUpload(options?: UploadOptions): Upload {
|
|
413
|
+
if (!_uploadInstance) {
|
|
414
|
+
_uploadInstance = new Upload(options);
|
|
415
|
+
}
|
|
416
|
+
return _uploadInstance;
|
|
417
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation helper — CodeIgniter-style string rules + Zod integration.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* 1. String rules: `this.validate(body, { name: 'required|min:2' })`
|
|
6
|
+
* 2. Zod schema: `this.validate(body, z.object({ name: z.string().min(2) }))`
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* // String rules (CodeIgniter style)
|
|
11
|
+
* const v = this.validate(this.body, {
|
|
12
|
+
* name: 'required|min:2|max:100',
|
|
13
|
+
* email: 'required|email'
|
|
14
|
+
* })
|
|
15
|
+
* if (v.fails()) return this.badRequest(v.errors())
|
|
16
|
+
*
|
|
17
|
+
* // Zod schema (TypeScript style)
|
|
18
|
+
* const schema = z.object({ name: z.string().min(2), email: z.string().email() })
|
|
19
|
+
* const v = this.validate(this.body, schema)
|
|
20
|
+
* if (v.fails()) return this.badRequest(v.errors())
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import type { z } from 'zod'
|
|
24
|
+
|
|
25
|
+
// ─── Types ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Validation errors map: field → messages[] */
|
|
28
|
+
export interface ValidationErrors {
|
|
29
|
+
[field: string]: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Validation result. */
|
|
33
|
+
export class ValidationResult<T = Record<string, any>> {
|
|
34
|
+
constructor(
|
|
35
|
+
public passes: boolean,
|
|
36
|
+
public data: T,
|
|
37
|
+
public errors: ValidationErrors = {}
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
fails(): boolean {
|
|
41
|
+
return !this.passes
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Get first error for a field. */
|
|
45
|
+
first(field: string): string | null {
|
|
46
|
+
return this.errors[field]?.[0] ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Get all errors for a field. */
|
|
50
|
+
get(field: string): string[] {
|
|
51
|
+
return this.errors[field] ?? []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Convert to plain object (for JSON response). */
|
|
55
|
+
toJSON(): { passes: boolean; errors: ValidationErrors } {
|
|
56
|
+
return { passes: this.passes, errors: this.errors }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── String Rule Validator ─────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
type Rules = Record<string, string>
|
|
63
|
+
|
|
64
|
+
/** Built-in validation rules. */
|
|
65
|
+
const RULES: Record<string, (value: any, param?: string) => string | null> = {
|
|
66
|
+
required: (value) =>
|
|
67
|
+
value === undefined || value === null || value === '' ? 'This field is required' : null,
|
|
68
|
+
|
|
69
|
+
min: (value, param) => {
|
|
70
|
+
const min = Number(param)
|
|
71
|
+
if (isNaN(min)) return null
|
|
72
|
+
if (typeof value === 'string' && value.length < min) return `Must be at least ${min} characters`
|
|
73
|
+
if (typeof value === 'number' && value < min) return `Must be at least ${min}`
|
|
74
|
+
return null
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
max: (value, param) => {
|
|
78
|
+
const max = Number(param)
|
|
79
|
+
if (isNaN(max)) return null
|
|
80
|
+
if (typeof value === 'string' && value.length > max) return `Must not exceed ${max} characters`
|
|
81
|
+
if (typeof value === 'number' && value > max) return `Must not exceed ${max}`
|
|
82
|
+
return null
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
email: (value) =>
|
|
86
|
+
typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
87
|
+
? 'Must be a valid email address'
|
|
88
|
+
: null,
|
|
89
|
+
|
|
90
|
+
numeric: (value) =>
|
|
91
|
+
typeof value !== 'number' && isNaN(Number(value)) ? 'Must be a number' : null,
|
|
92
|
+
|
|
93
|
+
integer: (value) =>
|
|
94
|
+
!Number.isInteger(Number(value)) ? 'Must be an integer' : null,
|
|
95
|
+
|
|
96
|
+
boolean: (value) =>
|
|
97
|
+
typeof value !== 'boolean' && !['true', 'false', '0', '1'].includes(String(value))
|
|
98
|
+
? 'Must be a boolean'
|
|
99
|
+
: null,
|
|
100
|
+
|
|
101
|
+
url: (value) =>
|
|
102
|
+
typeof value === 'string' && !/^https?:\/\/.+/.test(value) ? 'Must be a valid URL' : null,
|
|
103
|
+
|
|
104
|
+
alpha: (value) =>
|
|
105
|
+
typeof value === 'string' && !/^[a-zA-Z]+$/.test(value) ? 'Must contain only letters' : null,
|
|
106
|
+
|
|
107
|
+
alpha_num: (value) =>
|
|
108
|
+
typeof value === 'string' && !/^[a-zA-Z0-9]+$/.test(value)
|
|
109
|
+
? 'Must contain only letters and numbers'
|
|
110
|
+
: null,
|
|
111
|
+
|
|
112
|
+
alpha_dash: (value) =>
|
|
113
|
+
typeof value === 'string' && !/^[a-zA-Z0-9_-]+$/.test(value)
|
|
114
|
+
? 'Must contain only letters, numbers, dashes, and underscores'
|
|
115
|
+
: null,
|
|
116
|
+
|
|
117
|
+
same: (value, param, data) => {
|
|
118
|
+
if (!param) return null
|
|
119
|
+
return data?.[param] !== value ? `Must match ${param}` : null
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
differs: (value, param, data) => {
|
|
123
|
+
if (!param) return null
|
|
124
|
+
return data?.[param] === value ? `Must differ from ${param}` : null
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
regex: (value, param) => {
|
|
128
|
+
if (!param) return null
|
|
129
|
+
try {
|
|
130
|
+
return new RegExp(param).test(String(value)) ? null : 'Format is invalid'
|
|
131
|
+
} catch {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
date: (value) =>
|
|
137
|
+
typeof value === 'string' && isNaN(Date.parse(value)) ? 'Must be a valid date' : null,
|
|
138
|
+
|
|
139
|
+
after: (value, param) => {
|
|
140
|
+
if (!param || typeof value !== 'string') return null
|
|
141
|
+
return Date.parse(value) <= Date.parse(param) ? `Must be after ${param}` : null
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
before: (value, param) => {
|
|
145
|
+
if (!param || typeof value !== 'string') return null
|
|
146
|
+
return Date.parse(value) >= Date.parse(param) ? `Must be before ${param}` : null
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
'size': (value, param) => {
|
|
150
|
+
const size = Number(param)
|
|
151
|
+
if (isNaN(size)) return null
|
|
152
|
+
if (typeof value === 'string' && value.length !== size) return `Must be exactly ${size} characters`
|
|
153
|
+
if (typeof value === 'number' && value !== size) return `Must be exactly ${size}`
|
|
154
|
+
if (Array.isArray(value) && value.length !== size) return `Must contain exactly ${size} items`
|
|
155
|
+
return null
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
'required_if': (value, param, data) => {
|
|
159
|
+
if (!param) return null
|
|
160
|
+
const [field, ...rest] = param.split(',')
|
|
161
|
+
const expected = rest.join(',')
|
|
162
|
+
if (data?.[field] === expected && (value === undefined || value === null || value === '')) {
|
|
163
|
+
return 'This field is required'
|
|
164
|
+
}
|
|
165
|
+
return null
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Validate data against string rules (CodeIgniter-style).
|
|
171
|
+
*
|
|
172
|
+
* @param data - The data to validate (usually `this.body`)
|
|
173
|
+
* @param rules - Object mapping field names to pipe-separated rule strings
|
|
174
|
+
* @returns ValidationResult
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* const v = validateStringRules(this.body, {
|
|
179
|
+
* name: 'required|min:2|max:100',
|
|
180
|
+
* email: 'required|email',
|
|
181
|
+
* age: 'numeric|min:18',
|
|
182
|
+
* })
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
export function validateStringRules<T extends Record<string, any>>(
|
|
186
|
+
data: T,
|
|
187
|
+
rules: Rules
|
|
188
|
+
): ValidationResult<T> {
|
|
189
|
+
const errors: ValidationErrors = {}
|
|
190
|
+
const validData: Record<string, any> = { ...data }
|
|
191
|
+
|
|
192
|
+
for (const [field, ruleString] of Object.entries(rules)) {
|
|
193
|
+
const ruleList = ruleString.split('|').map(r => r.trim()).filter(Boolean)
|
|
194
|
+
const value = data[field]
|
|
195
|
+
|
|
196
|
+
for (const ruleDef of ruleList) {
|
|
197
|
+
const [ruleName, ...paramParts] = ruleDef.split(':')
|
|
198
|
+
const param = paramParts.join(':')
|
|
199
|
+
|
|
200
|
+
const ruleFn = RULES[ruleName]
|
|
201
|
+
if (!ruleFn) continue
|
|
202
|
+
|
|
203
|
+
const error = ruleFn(value, param, data)
|
|
204
|
+
if (error) {
|
|
205
|
+
(errors[field] ??= []).push(error)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Trim values
|
|
210
|
+
if (typeof validData[field] === 'string') {
|
|
211
|
+
validData[field] = validData[field].trim()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return new ValidationResult<T>(
|
|
216
|
+
Object.keys(errors).length === 0,
|
|
217
|
+
validData as T,
|
|
218
|
+
errors
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Validate data against a Zod schema.
|
|
224
|
+
*
|
|
225
|
+
* @param data - The data to validate
|
|
226
|
+
* @param schema - Zod schema
|
|
227
|
+
* @returns ValidationResult
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```ts
|
|
231
|
+
* const schema = z.object({ name: z.string().min(2), email: z.string().email() })
|
|
232
|
+
* const v = validateZod(this.body, schema)
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
export function validateZod<T>(
|
|
236
|
+
data: unknown,
|
|
237
|
+
schema: z.ZodSchema<T>
|
|
238
|
+
): ValidationResult<T> {
|
|
239
|
+
const result = schema.safeParse(data)
|
|
240
|
+
if (result.success) {
|
|
241
|
+
return new ValidationResult(true, result.data, {})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const errors: ValidationErrors = {}
|
|
245
|
+
for (const issue of result.error.issues) {
|
|
246
|
+
const path = issue.path.join('.')
|
|
247
|
+
;(errors[path] ??= []).push(issue.message)
|
|
248
|
+
}
|
|
249
|
+
return new ValidationResult(false, data as T, errors)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Universal validate — auto-detects string rules vs Zod schema.
|
|
254
|
+
*/
|
|
255
|
+
export function validate<T extends Record<string, any>>(
|
|
256
|
+
data: unknown,
|
|
257
|
+
schemaOrRules: z.ZodSchema<T> | Rules
|
|
258
|
+
): ValidationResult<T> {
|
|
259
|
+
// Detect Zod schema (it's an object with safeParse method)
|
|
260
|
+
if (schemaOrRules && typeof schemaOrRules === 'object' && 'safeParse' in schemaOrRules) {
|
|
261
|
+
return validateZod(data, schemaOrRules as unknown as z.ZodSchema<T>)
|
|
262
|
+
}
|
|
263
|
+
return validateStringRules(data as Record<string, any>, schemaOrRules as Rules) as unknown as ValidationResult<T>
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Rule names for autocomplete. */
|
|
267
|
+
export const rules = {
|
|
268
|
+
required: 'required',
|
|
269
|
+
min: (n: number) => `min:${n}`,
|
|
270
|
+
max: (n: number) => `max:${n}`,
|
|
271
|
+
email: 'email',
|
|
272
|
+
numeric: 'numeric',
|
|
273
|
+
integer: 'integer',
|
|
274
|
+
boolean: 'boolean',
|
|
275
|
+
url: 'url',
|
|
276
|
+
alpha: 'alpha',
|
|
277
|
+
alphaNum: 'alpha_num',
|
|
278
|
+
alphaDash: 'alpha_dash',
|
|
279
|
+
same: (field: string) => `same:${field}`,
|
|
280
|
+
differs: (field: string) => `differs:${field}`,
|
|
281
|
+
regex: (pattern: string) => `regex:${pattern}`,
|
|
282
|
+
date: 'date',
|
|
283
|
+
after: (date: string) => `after:${date}`,
|
|
284
|
+
before: (date: string) => `before:${date}`,
|
|
285
|
+
size: (n: number) => `size:${n}`,
|
|
286
|
+
requiredIf: (field: string, value: string) => `required_if:${field},${value}`,
|
|
287
|
+
} as const
|