webspresso 0.0.102 → 0.0.103
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/.agents/ORM.md +13 -4
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
- package/README.md +2 -2
- package/core/auth/jwt.js +10 -17
- package/core/auth/user-agent.js +61 -42
- package/core/compression/compression-stream.js +2 -2
- package/core/compression/middleware.js +1 -1
- package/core/compression/negotiate.js +11 -4
- package/core/events/dispatcher.js +56 -33
- package/core/openapi/orm-components.js +2 -3
- package/core/orm/cache/listeners.js +12 -2
- package/core/orm/json-fields.js +7 -1
- package/core/orm/model.js +99 -8
- package/core/orm/query-builder.js +71 -25
- package/core/orm/repository.js +42 -17
- package/core/orm/schema-helpers.js +49 -0
- package/core/orm/scopes.js +40 -9
- package/core/orm/types.js +12 -1
- package/core/queue/manager.js +5 -0
- package/core/validation/index.js +9 -3
- package/index.d.ts +22 -5
- package/package.json +2 -1
- package/plugins/audit-log/middleware.js +39 -4
- package/plugins/email/service.js +32 -1
- package/plugins/file-manager/api-handlers.js +10 -17
- package/plugins/file-manager/index.js +11 -9
- package/plugins/file-manager/security.js +18 -6
- package/plugins/site-analytics/bot-patterns.js +3 -0
- package/plugins/site-analytics/client-error-handler.js +3 -1
- package/plugins/site-analytics/index.js +10 -1
- package/plugins/site-analytics/tracking.js +245 -46
- package/src/api/api-loader.js +1 -1
- package/src/discovery/scan-routes.js +14 -4
- package/src/file-router.js +62 -6
- package/src/helpers.js +139 -26
- package/src/plugin-manager.js +34 -13
- package/src/routing/route-table.js +14 -4
- package/src/server.js +23 -7
- package/src/services/validator.js +57 -32
package/core/orm/types.js
CHANGED
|
@@ -150,12 +150,23 @@
|
|
|
150
150
|
// Model Types
|
|
151
151
|
// ============================================================================
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* @typedef {Object} ModelConfigOptions
|
|
155
|
+
* @property {boolean|{createdAt?: string, updatedAt?: string, snakeCase?: boolean}} [timestamps=false] - Auto timestamps
|
|
156
|
+
* @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDelete=false] - Soft delete support
|
|
157
|
+
* @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDeletes=false] - Soft delete support alias
|
|
158
|
+
*/
|
|
159
|
+
|
|
153
160
|
/**
|
|
154
161
|
* @typedef {Object} ModelOptions
|
|
155
162
|
* @property {string} name - Model name (e.g., 'User')
|
|
156
163
|
* @property {string} table - Database table name (e.g., 'users')
|
|
157
|
-
* @property {import('zod').ZodObject} schema - Zod schema
|
|
164
|
+
* @property {import('zod').ZodObject|Record<string, any>} schema - Zod schema or plain object schema shape
|
|
158
165
|
* @property {string} [primaryKey='id'] - Primary key column name
|
|
166
|
+
* @property {boolean|{createdAt?: string, updatedAt?: string, snakeCase?: boolean}} [timestamps] - Auto timestamps (injects createdAt/updatedAt)
|
|
167
|
+
* @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDelete] - Soft delete support (injects deletedAt)
|
|
168
|
+
* @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDeletes] - Soft delete support alias
|
|
169
|
+
* @property {ModelConfigOptions} [options] - Additional model options (e.g. timestamps, softDelete)
|
|
159
170
|
* @property {RelationsMap} [relations={}] - Relation definitions
|
|
160
171
|
* @property {ScopeOptions} [scopes={}] - Scope options
|
|
161
172
|
* @property {AdminMetadata} [admin] - Admin panel metadata
|
package/core/queue/manager.js
CHANGED
|
@@ -527,6 +527,11 @@ class QueueManager extends EventEmitter {
|
|
|
527
527
|
}
|
|
528
528
|
} catch (err) {
|
|
529
529
|
this.emit('error', err);
|
|
530
|
+
// Back off polling interval on database or adapter error to avoid tight spin-loop
|
|
531
|
+
this._currentPollInterval = Math.min(
|
|
532
|
+
this.maxPollInterval,
|
|
533
|
+
Math.round(this._currentPollInterval * 2)
|
|
534
|
+
);
|
|
530
535
|
this._scheduleNextTick(this._currentPollInterval);
|
|
531
536
|
return;
|
|
532
537
|
}
|
package/core/validation/index.js
CHANGED
|
@@ -22,13 +22,19 @@ function hasDangerousKeys(obj) {
|
|
|
22
22
|
return false;
|
|
23
23
|
}
|
|
24
24
|
if (Array.isArray(obj)) {
|
|
25
|
-
|
|
25
|
+
const len = obj.length;
|
|
26
|
+
for (let i = 0; i < len; i++) {
|
|
27
|
+
if (hasDangerousKeys(obj[i])) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
26
30
|
}
|
|
27
|
-
for (const key
|
|
31
|
+
for (const key in obj) {
|
|
32
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
28
33
|
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
29
34
|
return true;
|
|
30
35
|
}
|
|
31
|
-
|
|
36
|
+
const val = obj[key];
|
|
37
|
+
if (val !== null && typeof val === 'object' && hasDangerousKeys(val)) {
|
|
32
38
|
return true;
|
|
33
39
|
}
|
|
34
40
|
}
|
package/index.d.ts
CHANGED
|
@@ -1072,11 +1072,20 @@ export type InferModel<T> = T extends ModelDefinition<infer TModel>
|
|
|
1072
1072
|
export type InferModelCreate<T> = Partial<InferModel<T>> & Record<string, any>;
|
|
1073
1073
|
export type InferModelUpdate<T> = Partial<InferModel<T>>;
|
|
1074
1074
|
|
|
1075
|
-
export interface ModelOptions<TSchema
|
|
1075
|
+
export interface ModelOptions<TSchema = ZodObject<Record<string, ZodTypeAny>>> {
|
|
1076
1076
|
name: string;
|
|
1077
1077
|
table: string;
|
|
1078
|
-
schema: TSchema
|
|
1078
|
+
schema: TSchema | Record<string, any>;
|
|
1079
1079
|
primaryKey?: string;
|
|
1080
|
+
timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
|
|
1081
|
+
softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1082
|
+
softDeletes?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1083
|
+
options?: {
|
|
1084
|
+
timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
|
|
1085
|
+
softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1086
|
+
softDeletes?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1087
|
+
[key: string]: unknown;
|
|
1088
|
+
};
|
|
1080
1089
|
relations?: Record<string, RelationDefinition>;
|
|
1081
1090
|
scopes?: ScopeOptions;
|
|
1082
1091
|
admin?: AdminMetadata;
|
|
@@ -1104,6 +1113,11 @@ export interface ModelDefinition<TModel = Record<string, any>> {
|
|
|
1104
1113
|
timestamps: boolean;
|
|
1105
1114
|
tenant: string | null;
|
|
1106
1115
|
};
|
|
1116
|
+
options?: {
|
|
1117
|
+
timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
|
|
1118
|
+
softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1119
|
+
[key: string]: unknown;
|
|
1120
|
+
};
|
|
1107
1121
|
columns: Map<string, unknown>;
|
|
1108
1122
|
admin: {
|
|
1109
1123
|
enabled: boolean;
|
|
@@ -1129,9 +1143,9 @@ export interface ModelDefinition<TModel = Record<string, any>> {
|
|
|
1129
1143
|
cache?: boolean | 'auto' | 'smart' | { strategy: 'auto' | 'smart' };
|
|
1130
1144
|
}
|
|
1131
1145
|
|
|
1132
|
-
export function defineModel<TSchema
|
|
1146
|
+
export function defineModel<TSchema = ZodObject<any>>(
|
|
1133
1147
|
options: ModelOptions<TSchema>
|
|
1134
|
-
): ModelDefinition<
|
|
1148
|
+
): ModelDefinition<any>;
|
|
1135
1149
|
|
|
1136
1150
|
export function getModel<T = Record<string, any>>(name: string): ModelDefinition<T> | undefined;
|
|
1137
1151
|
|
|
@@ -1147,7 +1161,10 @@ export function isCascadeEnabled(relation: RelationDefinition, operation: 'delet
|
|
|
1147
1161
|
|
|
1148
1162
|
/** zdb column helpers (id, string, timestamp, …) + `schema()` */
|
|
1149
1163
|
export type Zdb = Record<string, any> & {
|
|
1150
|
-
schema(shape: Record<string,
|
|
1164
|
+
schema(shape: Record<string, any>): ZodObject<Record<string, ZodTypeAny>>;
|
|
1165
|
+
timestamps(options?: { snakeCase?: boolean; createdAt?: string; updatedAt?: string }): Record<string, any>;
|
|
1166
|
+
softDelete(options?: Record<string, any>): any;
|
|
1167
|
+
softDeletes(options?: { snakeCase?: boolean; deletedAt?: string }): Record<string, any>;
|
|
1151
1168
|
};
|
|
1152
1169
|
|
|
1153
1170
|
export const zdb: Zdb;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.103",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -176,6 +176,7 @@
|
|
|
176
176
|
"dotenv": "^16.3.1",
|
|
177
177
|
"express-rate-limit": "^8.4.1",
|
|
178
178
|
"release-it": "^20.2.1",
|
|
179
|
+
"softscope": "^0.3.2",
|
|
179
180
|
"supertest": "^6.3.4",
|
|
180
181
|
"typescript": "~5.6.3",
|
|
181
182
|
"vitest": "^4.1.11",
|
|
@@ -95,11 +95,9 @@ function createAuditMiddleware(options) {
|
|
|
95
95
|
metadata: metadata || null,
|
|
96
96
|
};
|
|
97
97
|
|
|
98
|
-
knex(tableName).insert(row).catch(
|
|
99
|
-
console.warn('[audit-log] Failed to insert audit row:', err.message);
|
|
100
|
-
});
|
|
98
|
+
knex(tableName).insert(row).catch(logAuditError);
|
|
101
99
|
} catch (e) {
|
|
102
|
-
|
|
100
|
+
logAuditError(e, 'finish handler error');
|
|
103
101
|
}
|
|
104
102
|
});
|
|
105
103
|
|
|
@@ -107,9 +105,46 @@ function createAuditMiddleware(options) {
|
|
|
107
105
|
};
|
|
108
106
|
}
|
|
109
107
|
|
|
108
|
+
let lastAuditWarnTime = 0;
|
|
109
|
+
let suppressedAuditErrors = 0;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Format and sanitize DB errors to prevent leaking raw SQL queries
|
|
113
|
+
* @param {Error|Object|string} e
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function formatDbError(e) {
|
|
117
|
+
if (!e) return 'Unknown error';
|
|
118
|
+
let msg;
|
|
119
|
+
if (typeof e.message === 'string' && e.message.includes(' - ')) {
|
|
120
|
+
msg = e.message.split(' - ').pop().trim();
|
|
121
|
+
} else {
|
|
122
|
+
msg = String(e.code || e.message || 'Database error');
|
|
123
|
+
}
|
|
124
|
+
return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function logAuditError(err, prefix = 'Failed to insert audit row') {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const formatted = formatDbError(err);
|
|
130
|
+
if (lastAuditWarnTime === 0 || now - lastAuditWarnTime >= 60000) {
|
|
131
|
+
const elapsedSec = lastAuditWarnTime > 0 ? Math.round((now - lastAuditWarnTime) / 1000) : 0;
|
|
132
|
+
if (suppressedAuditErrors > 0) {
|
|
133
|
+
console.warn(`[audit-log] ${prefix} (suppressed ${suppressedAuditErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
|
|
134
|
+
} else {
|
|
135
|
+
console.warn(`[audit-log] ${prefix}: ${formatted}`);
|
|
136
|
+
}
|
|
137
|
+
lastAuditWarnTime = now;
|
|
138
|
+
suppressedAuditErrors = 0;
|
|
139
|
+
} else {
|
|
140
|
+
suppressedAuditErrors++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
110
144
|
module.exports = {
|
|
111
145
|
createAuditMiddleware,
|
|
112
146
|
stringifyId,
|
|
113
147
|
extractResourceIdFromJsonBody,
|
|
114
148
|
buildMetadata,
|
|
149
|
+
formatDbError,
|
|
115
150
|
};
|
package/plugins/email/service.js
CHANGED
|
@@ -30,6 +30,37 @@ function createEmailService(options) {
|
|
|
30
30
|
console.warn('[email]', err.message);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
let lastEmailLogWarnTime = 0;
|
|
34
|
+
let suppressedEmailLogErrors = 0;
|
|
35
|
+
|
|
36
|
+
function formatDbError(e) {
|
|
37
|
+
if (!e) return 'Unknown error';
|
|
38
|
+
let msg;
|
|
39
|
+
if (typeof e.message === 'string' && e.message.includes(' - ')) {
|
|
40
|
+
msg = e.message.split(' - ').pop().trim();
|
|
41
|
+
} else {
|
|
42
|
+
msg = String(e.code || e.message || 'Database error');
|
|
43
|
+
}
|
|
44
|
+
return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function logEmailError(err) {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const formatted = formatDbError(err);
|
|
50
|
+
if (lastEmailLogWarnTime === 0 || now - lastEmailLogWarnTime >= 60000) {
|
|
51
|
+
const elapsedSec = lastEmailLogWarnTime > 0 ? Math.round((now - lastEmailLogWarnTime) / 1000) : 0;
|
|
52
|
+
if (suppressedEmailLogErrors > 0) {
|
|
53
|
+
console.warn(`[email] Failed to write email log (suppressed ${suppressedEmailLogErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
|
|
54
|
+
} else {
|
|
55
|
+
console.warn('[email] Failed to write email log:', formatted);
|
|
56
|
+
}
|
|
57
|
+
lastEmailLogWarnTime = now;
|
|
58
|
+
suppressedEmailLogErrors = 0;
|
|
59
|
+
} else {
|
|
60
|
+
suppressedEmailLogErrors++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
/**
|
|
34
65
|
* @param {Object} row
|
|
35
66
|
*/
|
|
@@ -49,7 +80,7 @@ function createEmailService(options) {
|
|
|
49
80
|
created_at: new Date(),
|
|
50
81
|
});
|
|
51
82
|
} catch (err) {
|
|
52
|
-
|
|
83
|
+
logEmailError(err);
|
|
53
84
|
}
|
|
54
85
|
}
|
|
55
86
|
|
|
@@ -4,15 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const multer = require('multer');
|
|
7
|
-
const
|
|
8
|
-
listFiles,
|
|
9
|
-
createDirectory,
|
|
10
|
-
renameItem,
|
|
11
|
-
deleteItem,
|
|
12
|
-
moveItem,
|
|
13
|
-
saveUploadedFile,
|
|
14
|
-
calculateStorageUsage,
|
|
15
|
-
} = require('./core');
|
|
7
|
+
const core = require('./core');
|
|
16
8
|
|
|
17
9
|
/**
|
|
18
10
|
* Create Multer middleware for in-memory upload parsing
|
|
@@ -71,7 +63,7 @@ function createFileManagerApiHandlers(options = {}) {
|
|
|
71
63
|
const permissions = options.permissions || null;
|
|
72
64
|
const securityOpts = options.security || {};
|
|
73
65
|
|
|
74
|
-
const parseUpload = createUploadMiddleware(maxUploadBytes);
|
|
66
|
+
const parseUpload = options.uploadMiddleware || createUploadMiddleware(maxUploadBytes);
|
|
75
67
|
|
|
76
68
|
return {
|
|
77
69
|
async list(req, res) {
|
|
@@ -88,7 +80,7 @@ function createFileManagerApiHandlers(options = {}) {
|
|
|
88
80
|
const page = req.query.page ? parseInt(req.query.page, 10) : undefined;
|
|
89
81
|
const perPage = req.query.perPage ? parseInt(req.query.perPage, 10) : undefined;
|
|
90
82
|
|
|
91
|
-
const result = await listFiles(baseDir, relPath, {
|
|
83
|
+
const result = await core.listFiles(baseDir, relPath, {
|
|
92
84
|
search,
|
|
93
85
|
type,
|
|
94
86
|
sort,
|
|
@@ -115,7 +107,7 @@ function createFileManagerApiHandlers(options = {}) {
|
|
|
115
107
|
if (!name) {
|
|
116
108
|
return res.status(400).json({ success: false, error: 'Directory name is required' });
|
|
117
109
|
}
|
|
118
|
-
const result = await createDirectory(baseDir, relPath, name);
|
|
110
|
+
const result = await core.createDirectory(baseDir, relPath, name);
|
|
119
111
|
return res.status(201).json({ success: true, data: result });
|
|
120
112
|
} catch (err) {
|
|
121
113
|
const status = err.statusCode || err.status || 400;
|
|
@@ -133,7 +125,7 @@ function createFileManagerApiHandlers(options = {}) {
|
|
|
133
125
|
if (!itemPath || !newName) {
|
|
134
126
|
return res.status(400).json({ success: false, error: 'Path and newName are required' });
|
|
135
127
|
}
|
|
136
|
-
const result = await renameItem(baseDir, itemPath, newName);
|
|
128
|
+
const result = await core.renameItem(baseDir, itemPath, newName);
|
|
137
129
|
return res.json({ success: true, data: result });
|
|
138
130
|
} catch (err) {
|
|
139
131
|
const status = err.statusCode || err.status || 400;
|
|
@@ -151,7 +143,7 @@ function createFileManagerApiHandlers(options = {}) {
|
|
|
151
143
|
if (!source) {
|
|
152
144
|
return res.status(400).json({ success: false, error: 'Source path is required' });
|
|
153
145
|
}
|
|
154
|
-
const result = await moveItem(baseDir, source, destination);
|
|
146
|
+
const result = await core.moveItem(baseDir, source, destination);
|
|
155
147
|
return res.json({ success: true, data: result });
|
|
156
148
|
} catch (err) {
|
|
157
149
|
const status = err.statusCode || err.status || 400;
|
|
@@ -169,7 +161,7 @@ function createFileManagerApiHandlers(options = {}) {
|
|
|
169
161
|
if (!itemPath) {
|
|
170
162
|
return res.status(400).json({ success: false, error: 'Path is required' });
|
|
171
163
|
}
|
|
172
|
-
const result = await deleteItem(baseDir, itemPath);
|
|
164
|
+
const result = await core.deleteItem(baseDir, itemPath);
|
|
173
165
|
return res.json({ success: true, data: result });
|
|
174
166
|
} catch (err) {
|
|
175
167
|
const status = err.statusCode || err.status || 400;
|
|
@@ -199,7 +191,7 @@ function createFileManagerApiHandlers(options = {}) {
|
|
|
199
191
|
|
|
200
192
|
// Storage Quota Check
|
|
201
193
|
if (maxTotalStorageBytes && typeof maxTotalStorageBytes === 'number') {
|
|
202
|
-
const currentUsage = await calculateStorageUsage(baseDir);
|
|
194
|
+
const currentUsage = await core.calculateStorageUsage(baseDir);
|
|
203
195
|
const incomingBytes = files.reduce((acc, f) => acc + (f.size || f.buffer?.length || 0), 0);
|
|
204
196
|
if (currentUsage + incomingBytes > maxTotalStorageBytes) {
|
|
205
197
|
return res.status(507).json({
|
|
@@ -229,7 +221,7 @@ function createFileManagerApiHandlers(options = {}) {
|
|
|
229
221
|
try {
|
|
230
222
|
const uploaded = [];
|
|
231
223
|
for (const file of files) {
|
|
232
|
-
const saved = await saveUploadedFile(
|
|
224
|
+
const saved = await core.saveUploadedFile(
|
|
233
225
|
baseDir,
|
|
234
226
|
targetRelPath,
|
|
235
227
|
file.buffer,
|
|
@@ -257,5 +249,6 @@ function createFileManagerApiHandlers(options = {}) {
|
|
|
257
249
|
|
|
258
250
|
module.exports = {
|
|
259
251
|
createFileManagerApiHandlers,
|
|
252
|
+
createUploadMiddleware,
|
|
260
253
|
isActionAllowed,
|
|
261
254
|
};
|
|
@@ -114,15 +114,17 @@ function fileManagerPlugin(options = {}) {
|
|
|
114
114
|
// so window.__openFileManagerPicker is available across all admin pages (models, records, rich-text)
|
|
115
115
|
if (typeof adminApi.getRegistry === 'function') {
|
|
116
116
|
const registry = adminApi.getRegistry();
|
|
117
|
-
registry
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
if (registry) {
|
|
118
|
+
registry.configure({
|
|
119
|
+
fileManager: {
|
|
120
|
+
enabled: true,
|
|
121
|
+
apiPrefix,
|
|
122
|
+
publicBasePath,
|
|
123
|
+
pageTitle,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
registry.registerClientComponent('files', componentCode);
|
|
127
|
+
}
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
adminApi.registerModule({
|
|
@@ -24,6 +24,16 @@ const DANGEROUS_EXTENSIONS = new Set([
|
|
|
24
24
|
'htaccess', 'htpasswd', 'ini', 'env', 'config', 'conf', 'passwd', 'shadow',
|
|
25
25
|
]);
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Common safe extensions allowed when passed as raw extensions (without filename dot)
|
|
29
|
+
*/
|
|
30
|
+
const COMMON_SAFE_EXTENSIONS = new Set([
|
|
31
|
+
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff',
|
|
32
|
+
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf',
|
|
33
|
+
'txt', 'csv', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'jsx', 'tsx',
|
|
34
|
+
'sql', 'yaml', 'yml', 'zip', 'tar', 'gz', 'mp3', 'wav', 'mp4', 'mov', 'webm',
|
|
35
|
+
]);
|
|
36
|
+
|
|
27
37
|
/**
|
|
28
38
|
* Known magic numbers table for high-speed signature verification.
|
|
29
39
|
* Only validates when extension matches known binary format.
|
|
@@ -118,9 +128,8 @@ function validateSafeExtension(filename) {
|
|
|
118
128
|
throw new ValidationError('Filename must have a valid extension');
|
|
119
129
|
}
|
|
120
130
|
|
|
121
|
-
// Check all
|
|
122
|
-
for (
|
|
123
|
-
const segment = parts[i];
|
|
131
|
+
// Check all segments against dangerous extensions to block double extensions and dangerous dotfiles (e.g. .env, payload.php.jpg)
|
|
132
|
+
for (const segment of parts) {
|
|
124
133
|
if (DANGEROUS_EXTENSIONS.has(segment)) {
|
|
125
134
|
throw new SecurityError(
|
|
126
135
|
`Dangerous extension or double-extension detected: .${segment}`,
|
|
@@ -129,11 +138,14 @@ function validateSafeExtension(filename) {
|
|
|
129
138
|
}
|
|
130
139
|
}
|
|
131
140
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
if (parts.length < 2 && !cleanName.startsWith('.')) {
|
|
142
|
+
if (COMMON_SAFE_EXTENSIONS.has(parts[0])) {
|
|
143
|
+
return parts[0];
|
|
144
|
+
}
|
|
145
|
+
throw new ValidationError('Filename must have a valid extension');
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
const finalExt = parts[parts.length - 1];
|
|
137
149
|
return finalExt;
|
|
138
150
|
}
|
|
139
151
|
|
|
@@ -27,9 +27,12 @@ const BOT_PATTERNS = [
|
|
|
27
27
|
{ pattern: /petalbot/i, name: 'PetalBot' },
|
|
28
28
|
{ pattern: /gptbot/i, name: 'GPTBot' },
|
|
29
29
|
{ pattern: /claudebot/i, name: 'ClaudeBot' },
|
|
30
|
+
{ pattern: /amazonbot/i, name: 'Amazon' },
|
|
30
31
|
{ pattern: /anthropic/i, name: 'Anthropic' },
|
|
31
32
|
{ pattern: /ccbot/i, name: 'CCBot' },
|
|
32
33
|
{ pattern: /bytespider/i, name: 'ByteSpider' },
|
|
34
|
+
{ pattern: /dataforseo/i, name: 'DataForSEO' },
|
|
35
|
+
{ pattern: /yisouspider/i, name: 'Yisou' },
|
|
33
36
|
{ pattern: /open-research-bot/i, name: 'open-research-bot' },
|
|
34
37
|
{ pattern: /ptb12/i, name: 'PTB12' },
|
|
35
38
|
{ pattern: /curl\//i, name: 'curl' },
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* @module plugins/site-analytics/client-error-handler
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const { formatDbError } = require('./tracking');
|
|
8
|
+
|
|
7
9
|
const DEFAULT_TABLE = 'analytics_client_errors';
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -71,7 +73,7 @@ function createErrorReportHandler(options) {
|
|
|
71
73
|
|
|
72
74
|
res.status(202).json({ ok: true });
|
|
73
75
|
} catch (e) {
|
|
74
|
-
console.error('[site-analytics] Client error report failed:', e
|
|
76
|
+
console.error('[site-analytics] Client error report failed:', formatDbError(e));
|
|
75
77
|
res.status(500).json({ error: 'Report failed' });
|
|
76
78
|
}
|
|
77
79
|
};
|
|
@@ -140,6 +140,8 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
let trackingMiddleware = null;
|
|
144
|
+
|
|
143
145
|
return {
|
|
144
146
|
name: 'site-analytics',
|
|
145
147
|
version: '1.0.0',
|
|
@@ -177,13 +179,17 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
177
179
|
register(ctx) {
|
|
178
180
|
const knex = db.knex || db;
|
|
179
181
|
|
|
180
|
-
|
|
182
|
+
trackingMiddleware = createTrackingMiddleware({
|
|
181
183
|
knex,
|
|
182
184
|
excludePaths,
|
|
183
185
|
trackBots,
|
|
184
186
|
tableName,
|
|
185
187
|
batchSize: options.batchSize ?? 20,
|
|
186
188
|
flushIntervalMs: options.flushIntervalMs ?? 3000,
|
|
189
|
+
maxRetries: options.maxRetries ?? 3,
|
|
190
|
+
maxBackoffMs: options.maxBackoffMs ?? 60000,
|
|
191
|
+
maxQueueSize: options.maxQueueSize ?? 5000,
|
|
192
|
+
errorLogIntervalMs: options.errorLogIntervalMs ?? 60000,
|
|
187
193
|
});
|
|
188
194
|
|
|
189
195
|
ctx.app.use(trackingMiddleware);
|
|
@@ -199,15 +205,18 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
199
205
|
|
|
200
206
|
return () => {
|
|
201
207
|
stopCleanup();
|
|
208
|
+
if (trackingMiddleware && trackingMiddleware.stop) trackingMiddleware.stop();
|
|
202
209
|
};
|
|
203
210
|
},
|
|
204
211
|
|
|
205
212
|
dispose() {
|
|
206
213
|
stopCleanup();
|
|
214
|
+
if (trackingMiddleware && trackingMiddleware.stop) trackingMiddleware.stop();
|
|
207
215
|
},
|
|
208
216
|
|
|
209
217
|
shutdown() {
|
|
210
218
|
stopCleanup();
|
|
219
|
+
if (trackingMiddleware && trackingMiddleware.stop) trackingMiddleware.stop();
|
|
211
220
|
},
|
|
212
221
|
|
|
213
222
|
onRoutesReady(ctx) {
|