webspresso 0.0.104 → 0.0.106
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/AGENTS.md +21 -2
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +220 -8
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/FLAGS.md +118 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/LOGGING.md +93 -0
- package/.agents/METRICS.md +127 -0
- package/.agents/MIGRATION_JOBS.md +95 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +38 -3
- package/.agents/QUEUE.md +42 -0
- package/.agents/RATE_LIMIT.md +147 -0
- package/.agents/RESILIENCE.md +235 -0
- package/.agents/SCALE_OUT.md +109 -0
- package/.agents/SECRETS.md +117 -0
- package/AGENTS.md +16 -0
- package/README.md +2 -2
- package/bin/commands/add-aws.js +396 -0
- package/bin/commands/add-ci.js +523 -0
- package/bin/commands/add-cloudflare.js +240 -0
- package/bin/commands/add-cloudrun.js +381 -0
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/add-migration-job.js +396 -0
- package/bin/commands/assets-sync.js +209 -0
- package/bin/commands/client-generate.js +52 -0
- package/bin/commands/console.js +53 -0
- package/bin/commands/db-migrate.js +47 -2
- package/bin/commands/db-studio.js +84 -0
- package/bin/commands/deploy-check.js +242 -13
- package/bin/commands/dev.js +1 -1
- package/bin/commands/export.js +87 -0
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/secrets-pull.js +208 -0
- package/bin/commands/start.js +1 -1
- package/bin/utils/db.js +7 -1
- package/bin/utils/project.js +2 -2
- package/bin/webspresso.js +22 -0
- package/core/app.js +421 -0
- package/core/auth/authenticate.js +93 -0
- package/core/auth/hash.js +5 -4
- package/core/auth/index.js +34 -4
- package/core/auth/jwt.js +5 -54
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +53 -10
- package/core/auth/middleware.js +16 -68
- package/core/auth/oauth.js +8 -17
- package/core/auth/redis-session.js +1008 -0
- package/core/auth/sessions.js +156 -0
- package/core/auth/totp.js +2 -1
- package/core/cache/byte-size.js +123 -0
- package/core/cache/cdn.js +296 -0
- package/core/cache/index.js +33 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +354 -0
- package/core/commands/registry.js +6 -20
- package/core/console/commands.js +148 -0
- package/core/console/context.js +260 -0
- package/core/console/eval.js +70 -0
- package/core/console/index.js +22 -0
- package/core/console/repl.js +102 -0
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +49 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +9 -7
- package/core/events/signal.js +7 -0
- package/core/export/discovery.js +191 -0
- package/core/export/exporter.js +277 -0
- package/core/export/index.js +24 -0
- package/core/flags/evaluator.js +252 -0
- package/core/flags/hasher.js +37 -0
- package/core/flags/index.js +21 -0
- package/core/flags/manager.js +263 -0
- package/core/flags/store.js +126 -0
- package/core/health/index.js +41 -0
- package/core/health/middleware.js +213 -0
- package/core/health/normalize.js +140 -0
- package/core/health/probes.js +203 -0
- package/core/http/adapter.js +74 -0
- package/core/http/express-adapter.js +46 -0
- package/core/http/hono-adapter.js +531 -0
- package/core/http/index.js +41 -0
- package/core/http/pipeline.js +485 -0
- package/core/index.js +336 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +325 -0
- package/core/limits/normalize.js +222 -0
- package/core/logger/formatters.js +264 -0
- package/core/logger/index.js +260 -0
- package/core/logger/trace.js +137 -0
- package/core/metrics/collectors.js +125 -0
- package/core/metrics/index.js +45 -0
- package/core/metrics/middleware.js +230 -0
- package/core/metrics/registry.js +366 -0
- package/core/orm/batch.js +70 -0
- package/core/orm/cache/fingerprint.js +2 -2
- package/core/orm/cache/memory-provider.js +27 -110
- package/core/orm/d1.js +265 -0
- package/core/orm/index.js +176 -13
- package/core/orm/json-fields.js +22 -8
- package/core/orm/migrate-cloud.js +194 -0
- package/core/orm/model.js +10 -0
- package/core/orm/pool.js +97 -0
- package/core/orm/query-builder.js +72 -9
- package/core/orm/repository.js +318 -19
- package/core/orm/schema-helpers.js +8 -10
- package/core/orm/scopes.js +321 -1
- package/core/orm/transaction.js +10 -2
- package/core/orm/utils.js +4 -86
- package/core/queue/adapters/cloudflare.js +131 -0
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/adapters/pubsub.js +400 -0
- package/core/queue/adapters/sqs.js +419 -0
- package/core/queue/adapters/wait-until.js +77 -0
- package/core/queue/index.js +8 -0
- package/core/queue/manager.js +66 -41
- package/core/rate-limit/index.js +22 -0
- package/core/rate-limit/limiter.js +219 -0
- package/core/rate-limit/stores/memory.js +129 -0
- package/core/rate-limit/stores/redis.js +151 -0
- package/core/rate-limit/utils.js +112 -0
- package/core/resilience/circuit-breaker.js +422 -0
- package/core/resilience/errors.js +63 -0
- package/core/resilience/index.js +53 -0
- package/core/resilience/registry.js +97 -0
- package/core/resilience/retry.js +203 -0
- package/core/schema-cache.js +9 -2
- package/core/secrets/index.js +113 -0
- package/core/secrets/manager.js +308 -0
- package/core/secrets/providers/aws.js +288 -0
- package/core/secrets/providers/env.js +44 -0
- package/core/secrets/providers/gcp.js +307 -0
- package/core/secrets/providers/memory.js +76 -0
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +129 -19
- package/core/stream/index.js +19 -0
- package/core/stream/text-stream.js +326 -0
- package/core/studio/index.js +43 -0
- package/core/studio/inspector.js +258 -0
- package/core/studio/server.js +325 -0
- package/core/studio/ui.js +681 -0
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +118 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +6 -5
- package/core/utils/fs.js +144 -0
- package/core/utils/hmac.js +6 -0
- package/core/utils/json.js +98 -0
- package/core/utils/object.js +106 -0
- package/core/utils/random.js +9 -0
- package/core/utils/regex.js +77 -0
- package/core/utils/sha256.js +6 -0
- package/core/utils/string.js +87 -0
- package/core/utils/strip-trailing-slash.js +6 -0
- package/core/utils/time.js +96 -0
- package/core/utils/timeout.js +348 -0
- package/core/utils/url.js +106 -0
- package/core/utils/walk.js +7 -0
- package/index.d.ts +1549 -2
- package/index.js +272 -2
- package/package.json +3 -2
- package/plugins/admin-panel/core/api-extensions.js +2 -5
- package/plugins/admin-panel/index.js +13 -6
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -3
- package/plugins/api-key/index.js +3 -2
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +6 -15
- package/plugins/audit-log/index.js +3 -1
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/basic-auth/index.js +3 -2
- package/plugins/cors.js +3 -2
- package/plugins/csrf/index.js +9 -12
- package/plugins/csv/index.js +8 -5
- package/plugins/dashboard/app.js +2 -2
- package/plugins/dashboard/index.js +5 -5
- package/plugins/data-exchange/record-selection.js +2 -5
- package/plugins/email/api-handlers.js +5 -18
- package/plugins/email/auth-bridge.js +3 -2
- package/plugins/email/index.js +11 -9
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +5 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/discovery.js +18 -17
- package/plugins/polar/src/api.js +2 -1
- package/plugins/polar/src/webhooks.js +5 -6
- package/plugins/queue/index.js +18 -2
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/redirect/index.js +16 -9
- package/plugins/rls/index.js +170 -0
- package/plugins/seo-checker/index.js +2 -2
- package/plugins/site-analytics/index.js +3 -1
- package/plugins/site-analytics/tracking.js +2 -2
- package/plugins/sitemap.js +3 -2
- package/plugins/swagger.js +12 -5
- package/plugins/upload/gcs-provider.js +148 -0
- package/plugins/upload/index.js +136 -77
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/upload/s3-provider.js +257 -0
- package/plugins/webhooks/crypto.js +6 -11
- package/plugins/webhooks/dispatcher.js +8 -8
- package/plugins/webhooks/index.js +33 -23
- package/plugins/webhooks/matcher.js +7 -11
- package/plugins/webhooks/sender.js +8 -11
- package/plugins/xlsx/index.js +7 -5
- package/src/api/api-loader.js +12 -95
- package/src/api/define-api.js +1 -0
- package/src/app-context.js +41 -5
- package/src/client-runtime/constants.js +10 -0
- package/src/client-runtime/mount.js +2 -3
- package/src/dev/banner.js +6 -2
- package/src/dev/client-gen.js +458 -0
- package/src/discovery/scan-routes.js +5 -2
- package/src/file-router.js +3 -3
- package/src/helpers.js +212 -24
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +13 -154
- package/src/plugin-manager.js +99 -18
- package/src/server.js +335 -133
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +95 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +15 -1
- package/src/services/validator.js +38 -20
- package/src/template/precompile.js +8 -19
package/plugins/upload/index.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
const multer = require('multer');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { createLocalFileProvider } = require('./local-file-provider');
|
|
9
|
+
const { ensureLeadingSlash } = require('../../core/utils/url');
|
|
10
|
+
const { ensureArray } = require('../../core/utils/object');
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* @typedef {import('express').Request} ExpressRequest
|
|
@@ -37,8 +39,7 @@ const { createLocalFileProvider } = require('./local-file-provider');
|
|
|
37
39
|
* @returns {string}
|
|
38
40
|
*/
|
|
39
41
|
function normalizeRoutePath(p) {
|
|
40
|
-
|
|
41
|
-
return s.startsWith('/') ? s : `/${s}`;
|
|
42
|
+
return ensureLeadingSlash((p || '/api/upload').trim());
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -118,9 +119,125 @@ function uploadPlugin(options = {}) {
|
|
|
118
119
|
publicBasePath: local?.publicBasePath,
|
|
119
120
|
});
|
|
120
121
|
|
|
121
|
-
const mw = (
|
|
122
|
+
const mw = ensureArray(middleware).filter(Boolean);
|
|
122
123
|
const parseMultipart = createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles);
|
|
123
124
|
|
|
125
|
+
function mountRoutes(ctx) {
|
|
126
|
+
if (!ctx || typeof ctx.addRoute !== 'function') return;
|
|
127
|
+
|
|
128
|
+
if (ctx.app && typeof ctx.app.set === 'function') {
|
|
129
|
+
ctx.app.set('webspresso.uploadPath', normalizedPath);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const handler = (req, res) => {
|
|
133
|
+
parseMultipart(req, res, async (err) => {
|
|
134
|
+
if (err) {
|
|
135
|
+
const { status, message } = multerErrorResponse(err);
|
|
136
|
+
return res.status(status).json({ error: message, message });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const files = multiple ? (req.files || []) : (req.file ? [req.file] : []);
|
|
140
|
+
if (files.length === 0) {
|
|
141
|
+
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Validate all files first
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
if (!file || !file.buffer) {
|
|
147
|
+
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const mime = file.mimetype || 'application/octet-stream';
|
|
151
|
+
const originalName = file.originalname || 'upload';
|
|
152
|
+
const ext = extensionFromName(originalName);
|
|
153
|
+
|
|
154
|
+
if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
|
|
155
|
+
return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
|
|
156
|
+
}
|
|
157
|
+
if (
|
|
158
|
+
extensionAllowlist &&
|
|
159
|
+
extensionAllowlist.length &&
|
|
160
|
+
(!ext || !extensionAllowlist.includes(ext))
|
|
161
|
+
) {
|
|
162
|
+
return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const uploadPromises = files.map(async (file) => {
|
|
168
|
+
const mime = file.mimetype || 'application/octet-stream';
|
|
169
|
+
const originalName = file.originalname || 'upload';
|
|
170
|
+
const result = await provider.put({
|
|
171
|
+
buffer: file.buffer,
|
|
172
|
+
originalName,
|
|
173
|
+
mimeType: mime,
|
|
174
|
+
size: file.size,
|
|
175
|
+
req,
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
url: result.publicUrl,
|
|
179
|
+
publicUrl: result.publicUrl,
|
|
180
|
+
key: result.key,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const results = await Promise.all(uploadPromises);
|
|
185
|
+
|
|
186
|
+
if (multiple) {
|
|
187
|
+
res.json(results);
|
|
188
|
+
} else {
|
|
189
|
+
res.json(results[0]);
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
193
|
+
res.status(500).json({ error: message, message });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
ctx.addRoute('post', normalizedPath, ...mw, handler);
|
|
199
|
+
|
|
200
|
+
// Direct presigned upload endpoint for cloud storage (AWS S3, GCS, R2)
|
|
201
|
+
if (options.presigned) {
|
|
202
|
+
const presignPath = options.presignedRoute ? ensureLeadingSlash(options.presignedRoute) : `${normalizedPath}/presign`;
|
|
203
|
+
const presignMw = ensureArray(options.presignedMiddleware || options.middleware || []);
|
|
204
|
+
|
|
205
|
+
ctx.addRoute('post', presignPath, ...presignMw, async (req, res) => {
|
|
206
|
+
if (!provider || typeof provider.createSignedUploadUrl !== 'function') {
|
|
207
|
+
return res.status(400).json({
|
|
208
|
+
error: 'Configured upload provider does not support direct presigned uploads',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const { originalName, mimeType, key, expiresInSeconds, expiresInMs } = req.body || {};
|
|
213
|
+
if (!originalName) {
|
|
214
|
+
return res.status(400).json({ error: 'originalName is required in request body' });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const result = await provider.createSignedUploadUrl({
|
|
219
|
+
originalName,
|
|
220
|
+
mimeType: mimeType || 'application/octet-stream',
|
|
221
|
+
key,
|
|
222
|
+
expiresInSeconds,
|
|
223
|
+
expiresInMs,
|
|
224
|
+
});
|
|
225
|
+
return res.json(result);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return res.status(500).json({ error: err.message || 'Failed to create presigned upload URL' });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
232
|
+
console.log(` Upload plugin: POST ${presignPath} (Presigned URL)`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
237
|
+
console.log(` Upload plugin: POST ${normalizedPath}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
124
241
|
return {
|
|
125
242
|
name: 'upload',
|
|
126
243
|
version: '1.0.0',
|
|
@@ -159,83 +276,13 @@ function uploadPlugin(options = {}) {
|
|
|
159
276
|
}
|
|
160
277
|
}
|
|
161
278
|
}
|
|
279
|
+
if (typeof ctx.addRoute === 'function' && !ctx.core) {
|
|
280
|
+
mountRoutes(ctx);
|
|
281
|
+
}
|
|
162
282
|
},
|
|
163
283
|
|
|
164
284
|
onRoutesReady(ctx) {
|
|
165
|
-
|
|
166
|
-
app.set('webspresso.uploadPath', normalizedPath);
|
|
167
|
-
|
|
168
|
-
const handler = (req, res) => {
|
|
169
|
-
parseMultipart(req, res, async (err) => {
|
|
170
|
-
if (err) {
|
|
171
|
-
const { status, message } = multerErrorResponse(err);
|
|
172
|
-
return res.status(status).json({ error: message, message });
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const files = multiple ? (req.files || []) : (req.file ? [req.file] : []);
|
|
176
|
-
if (files.length === 0) {
|
|
177
|
-
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Validate all files first
|
|
181
|
-
for (const file of files) {
|
|
182
|
-
if (!file || !file.buffer) {
|
|
183
|
-
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const mime = file.mimetype || 'application/octet-stream';
|
|
187
|
-
const originalName = file.originalname || 'upload';
|
|
188
|
-
const ext = extensionFromName(originalName);
|
|
189
|
-
|
|
190
|
-
if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
|
|
191
|
-
return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
|
|
192
|
-
}
|
|
193
|
-
if (
|
|
194
|
-
extensionAllowlist &&
|
|
195
|
-
extensionAllowlist.length &&
|
|
196
|
-
(!ext || !extensionAllowlist.includes(ext))
|
|
197
|
-
) {
|
|
198
|
-
return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
try {
|
|
203
|
-
const uploadPromises = files.map(async (file) => {
|
|
204
|
-
const mime = file.mimetype || 'application/octet-stream';
|
|
205
|
-
const originalName = file.originalname || 'upload';
|
|
206
|
-
const result = await provider.put({
|
|
207
|
-
buffer: file.buffer,
|
|
208
|
-
originalName,
|
|
209
|
-
mimeType: mime,
|
|
210
|
-
size: file.size,
|
|
211
|
-
req,
|
|
212
|
-
});
|
|
213
|
-
return {
|
|
214
|
-
url: result.publicUrl,
|
|
215
|
-
publicUrl: result.publicUrl,
|
|
216
|
-
key: result.key,
|
|
217
|
-
};
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
const results = await Promise.all(uploadPromises);
|
|
221
|
-
|
|
222
|
-
if (multiple) {
|
|
223
|
-
res.json(results);
|
|
224
|
-
} else {
|
|
225
|
-
res.json(results[0]);
|
|
226
|
-
}
|
|
227
|
-
} catch (e) {
|
|
228
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
229
|
-
res.status(500).json({ error: message, message });
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
ctx.addRoute('post', normalizedPath, ...mw, handler);
|
|
235
|
-
|
|
236
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
237
|
-
console.log(` Upload plugin: POST ${normalizedPath}`);
|
|
238
|
-
}
|
|
285
|
+
mountRoutes(ctx);
|
|
239
286
|
},
|
|
240
287
|
};
|
|
241
288
|
}
|
|
@@ -244,10 +291,22 @@ const {
|
|
|
244
291
|
scanOrphanedUploads,
|
|
245
292
|
pruneOrphanedUploads,
|
|
246
293
|
} = require('./orphan-pruner');
|
|
294
|
+
const {
|
|
295
|
+
createR2FileProvider,
|
|
296
|
+
createS3FileProvider,
|
|
297
|
+
generateStorageKey,
|
|
298
|
+
} = require('./s3-provider');
|
|
299
|
+
const {
|
|
300
|
+
createGcsFileProvider,
|
|
301
|
+
} = require('./gcs-provider');
|
|
247
302
|
|
|
248
303
|
module.exports = {
|
|
249
304
|
uploadPlugin,
|
|
250
305
|
createLocalFileProvider,
|
|
306
|
+
createR2FileProvider,
|
|
307
|
+
createS3FileProvider,
|
|
308
|
+
createGcsFileProvider,
|
|
309
|
+
generateStorageKey,
|
|
251
310
|
scanOrphanedUploads,
|
|
252
311
|
pruneOrphanedUploads,
|
|
253
312
|
};
|
|
@@ -11,6 +11,7 @@ const fs = require('fs/promises');
|
|
|
11
11
|
const fsSync = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const { normalizePublicBase } = require('./local-file-provider');
|
|
14
|
+
const { safeJsonParse } = require('../../core/utils/json');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Format bytes into human readable string
|
|
@@ -137,10 +138,10 @@ async function extractDbFileReferences(db) {
|
|
|
137
138
|
for (const [key, value] of Object.entries(row)) {
|
|
138
139
|
// If value is a JSON string, try to parse
|
|
139
140
|
if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) {
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
const parsed = safeJsonParse(value, null);
|
|
142
|
+
if (parsed) {
|
|
142
143
|
scanValue(parsed);
|
|
143
|
-
}
|
|
144
|
+
} else {
|
|
144
145
|
addStringReference(value);
|
|
145
146
|
}
|
|
146
147
|
} else {
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 & Cloudflare R2 Upload Storage Providers for Webspresso
|
|
3
|
+
* Enables direct file storage in Cloudflare R2 (Worker bindings or S3 API) and AWS S3/MinIO
|
|
4
|
+
* @module plugins/upload/s3-provider
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { randomBytes } = require('crypto');
|
|
11
|
+
const { resolveStoredExtension } = require('./local-file-provider');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a secure, unique object storage key
|
|
15
|
+
* @param {string} originalName
|
|
16
|
+
* @param {string} mimeType
|
|
17
|
+
* @param {string} [prefix='']
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function generateStorageKey(originalName, mimeType, prefix = '') {
|
|
21
|
+
const base = path.basename(originalName || 'upload');
|
|
22
|
+
let ext = path.extname(base).toLowerCase();
|
|
23
|
+
if (!/^\.[a-z0-9]{1,12}$/.test(ext)) {
|
|
24
|
+
ext = '';
|
|
25
|
+
}
|
|
26
|
+
ext = resolveStoredExtension(mimeType, ext);
|
|
27
|
+
|
|
28
|
+
const cleanPrefix = prefix ? prefix.replace(/^\/+|\/+$/g, '') + '/' : '';
|
|
29
|
+
const uniqueName = `${Date.now()}-${randomBytes(8).toString('hex')}${ext}`;
|
|
30
|
+
return `${cleanPrefix}${uniqueName}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cloudflare R2 Native Worker Storage Provider
|
|
35
|
+
* Zero-dependency: interacts directly with Cloudflare Workers R2Bucket binding (env.BUCKET)
|
|
36
|
+
* @param {Object} options
|
|
37
|
+
* @param {Object} options.bucket - Cloudflare R2 Bucket binding (e.g. env.BUCKET)
|
|
38
|
+
* @param {string} options.publicBaseUrl - Public CDN or R2 custom domain URL (e.g. 'https://cdn.example.com')
|
|
39
|
+
* @param {string} [options.prefix='uploads'] - Directory prefix inside bucket
|
|
40
|
+
* @returns {import('./index').UploadStorageProvider}
|
|
41
|
+
*/
|
|
42
|
+
function createR2FileProvider(options = {}) {
|
|
43
|
+
const { bucket, publicBaseUrl, prefix = 'uploads' } = options;
|
|
44
|
+
|
|
45
|
+
if (!bucket) {
|
|
46
|
+
throw new Error('[createR2FileProvider] R2 Bucket binding (env.BUCKET) is required.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const normalizedBaseUrl = (publicBaseUrl || '').replace(/\/+$/, '');
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
/**
|
|
53
|
+
* Store file in Cloudflare R2
|
|
54
|
+
*/
|
|
55
|
+
async put({ buffer, originalName, mimeType, size, req }) {
|
|
56
|
+
void size;
|
|
57
|
+
void req;
|
|
58
|
+
|
|
59
|
+
const key = generateStorageKey(originalName, mimeType, prefix);
|
|
60
|
+
|
|
61
|
+
if (typeof bucket.put !== 'function') {
|
|
62
|
+
throw new Error('[createR2FileProvider] Provided bucket binding does not implement .put() method.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await bucket.put(key, buffer, {
|
|
66
|
+
httpMetadata: {
|
|
67
|
+
contentType: mimeType || 'application/octet-stream',
|
|
68
|
+
},
|
|
69
|
+
customMetadata: {
|
|
70
|
+
originalName: originalName || '',
|
|
71
|
+
uploadedAt: new Date().toISOString(),
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const publicUrl = normalizedBaseUrl ? `${normalizedBaseUrl}/${key}` : `/${key}`;
|
|
76
|
+
return { publicUrl, key };
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Delete file from Cloudflare R2
|
|
81
|
+
* @param {string} key
|
|
82
|
+
*/
|
|
83
|
+
async delete(key) {
|
|
84
|
+
if (typeof bucket.delete === 'function') {
|
|
85
|
+
await bucket.delete(key);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generic S3 Storage Provider (AWS S3, MinIO, or S3-compatible R2)
|
|
95
|
+
* @param {Object} options
|
|
96
|
+
* @param {Object} options.s3Client - S3 Client instance (e.g. @aws-sdk/client-s3 S3Client)
|
|
97
|
+
* @param {string} options.bucket - Bucket name
|
|
98
|
+
* @param {string} [options.publicBaseUrl] - Public CDN / bucket base URL
|
|
99
|
+
* @param {string} [options.prefix='uploads'] - Key prefix
|
|
100
|
+
* @returns {import('./index').UploadStorageProvider}
|
|
101
|
+
*/
|
|
102
|
+
function createS3FileProvider(options = {}) {
|
|
103
|
+
const { s3Client, bucket, publicBaseUrl, prefix = 'uploads' } = options;
|
|
104
|
+
|
|
105
|
+
if (!s3Client || typeof s3Client.send !== 'function') {
|
|
106
|
+
throw new Error('[createS3FileProvider] An S3 client instance with a .send() method is required.');
|
|
107
|
+
}
|
|
108
|
+
if (!bucket) {
|
|
109
|
+
throw new Error('[createS3FileProvider] Bucket name is required.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const normalizedBaseUrl = (publicBaseUrl || '').replace(/\/+$/, '');
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
async put({ buffer, originalName, mimeType, size, req }) {
|
|
116
|
+
void size;
|
|
117
|
+
void req;
|
|
118
|
+
|
|
119
|
+
const key = generateStorageKey(originalName, mimeType, prefix);
|
|
120
|
+
|
|
121
|
+
// Dynamically load PutObjectCommand or construct Command payload
|
|
122
|
+
let command;
|
|
123
|
+
try {
|
|
124
|
+
const { PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
125
|
+
command = new PutObjectCommand({
|
|
126
|
+
Bucket: bucket,
|
|
127
|
+
Key: key,
|
|
128
|
+
Body: buffer,
|
|
129
|
+
ContentType: mimeType || 'application/octet-stream',
|
|
130
|
+
});
|
|
131
|
+
} catch (_) {
|
|
132
|
+
// Fallback for custom or duck-typed S3 client wrappers
|
|
133
|
+
command = {
|
|
134
|
+
constructor: { name: 'PutObjectCommand' },
|
|
135
|
+
input: {
|
|
136
|
+
Bucket: bucket,
|
|
137
|
+
Key: key,
|
|
138
|
+
Body: buffer,
|
|
139
|
+
ContentType: mimeType || 'application/octet-stream',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await s3Client.send(command);
|
|
145
|
+
|
|
146
|
+
const publicUrl = normalizedBaseUrl ? `${normalizedBaseUrl}/${key}` : `/${key}`;
|
|
147
|
+
return { publicUrl, key };
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async delete(key) {
|
|
151
|
+
try {
|
|
152
|
+
let command;
|
|
153
|
+
try {
|
|
154
|
+
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
|
155
|
+
command = new DeleteObjectCommand({
|
|
156
|
+
Bucket: bucket,
|
|
157
|
+
Key: key,
|
|
158
|
+
});
|
|
159
|
+
} catch (_) {
|
|
160
|
+
command = {
|
|
161
|
+
constructor: { name: 'DeleteObjectCommand' },
|
|
162
|
+
input: {
|
|
163
|
+
Bucket: bucket,
|
|
164
|
+
Key: key,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
await s3Client.send(command);
|
|
169
|
+
return true;
|
|
170
|
+
} catch (_) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Generate a signed PUT URL for direct browser-to-S3 client upload
|
|
177
|
+
* @param {Object} [params]
|
|
178
|
+
* @param {string} [params.originalName]
|
|
179
|
+
* @param {string} [params.mimeType]
|
|
180
|
+
* @param {number} [params.expiresInSeconds=900] - 15 minutes default
|
|
181
|
+
* @param {string} [params.key] - Custom key override
|
|
182
|
+
* @returns {Promise<{ uploadUrl: string, publicUrl: string, key: string, method: string, headers: Record<string, string> }>}
|
|
183
|
+
*/
|
|
184
|
+
async createSignedUploadUrl(params = {}) {
|
|
185
|
+
const {
|
|
186
|
+
originalName = 'upload',
|
|
187
|
+
mimeType = 'application/octet-stream',
|
|
188
|
+
expiresInSeconds = 900,
|
|
189
|
+
key: customKey,
|
|
190
|
+
} = params;
|
|
191
|
+
|
|
192
|
+
const key = customKey || generateStorageKey(originalName, mimeType, prefix);
|
|
193
|
+
const publicUrl = normalizedBaseUrl ? `${normalizedBaseUrl}/${key}` : `https://${bucket}.s3.amazonaws.com/${key}`;
|
|
194
|
+
|
|
195
|
+
// 1. Duck-typed client with direct getSignedUrl method (e.g. AWS SDK v2, MinIO, or mock)
|
|
196
|
+
if (typeof s3Client.getSignedUrl === 'function') {
|
|
197
|
+
const uploadUrl = await s3Client.getSignedUrl('putObject', {
|
|
198
|
+
Bucket: bucket,
|
|
199
|
+
Key: key,
|
|
200
|
+
ContentType: mimeType,
|
|
201
|
+
Expires: expiresInSeconds,
|
|
202
|
+
});
|
|
203
|
+
return {
|
|
204
|
+
uploadUrl,
|
|
205
|
+
publicUrl,
|
|
206
|
+
key,
|
|
207
|
+
method: 'PUT',
|
|
208
|
+
headers: { 'Content-Type': mimeType },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2. Custom signer function option
|
|
213
|
+
if (typeof options.createSignedUrl === 'function') {
|
|
214
|
+
const uploadUrl = await options.createSignedUrl({ bucket, key, mimeType, expiresInSeconds });
|
|
215
|
+
return {
|
|
216
|
+
uploadUrl,
|
|
217
|
+
publicUrl,
|
|
218
|
+
key,
|
|
219
|
+
method: 'PUT',
|
|
220
|
+
headers: { 'Content-Type': mimeType },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 3. AWS SDK v3 with @aws-sdk/s3-request-presigner
|
|
225
|
+
try {
|
|
226
|
+
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
|
227
|
+
const { PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
228
|
+
const uploadUrl = await getSignedUrl(
|
|
229
|
+
s3Client,
|
|
230
|
+
new PutObjectCommand({
|
|
231
|
+
Bucket: bucket,
|
|
232
|
+
Key: key,
|
|
233
|
+
ContentType: mimeType,
|
|
234
|
+
}),
|
|
235
|
+
{ expiresIn: expiresInSeconds }
|
|
236
|
+
);
|
|
237
|
+
return {
|
|
238
|
+
uploadUrl,
|
|
239
|
+
publicUrl,
|
|
240
|
+
key,
|
|
241
|
+
method: 'PUT',
|
|
242
|
+
headers: { 'Content-Type': mimeType },
|
|
243
|
+
};
|
|
244
|
+
} catch (err) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`[createS3FileProvider] Direct signed upload requires @aws-sdk/s3-request-presigner or a mock client with getSignedUrl: ${err.message}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
createR2FileProvider,
|
|
255
|
+
createS3FileProvider,
|
|
256
|
+
generateStorageKey,
|
|
257
|
+
};
|
|
@@ -4,9 +4,7 @@
|
|
|
4
4
|
* @module plugins/webhooks/crypto
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const crypto = require('crypto');
|
|
7
|
+
const { safeCompare, hmacSha256, randomId } = require('../../core/utils/crypto');
|
|
10
8
|
|
|
11
9
|
/**
|
|
12
10
|
* Generate a cryptographically secure webhook signing secret
|
|
@@ -16,12 +14,9 @@ const crypto = require('crypto');
|
|
|
16
14
|
* @returns {string} E.g. 'whsec_e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934c'
|
|
17
15
|
*/
|
|
18
16
|
function generateSecret(prefix = 'whsec_', byteLength = 24) {
|
|
19
|
-
|
|
20
|
-
return `${prefix || ''}${bytes}`;
|
|
17
|
+
return randomId(prefix, byteLength, 'hex');
|
|
21
18
|
}
|
|
22
19
|
|
|
23
|
-
const { safeCompare } = require('../../core/utils/crypto');
|
|
24
|
-
|
|
25
20
|
/**
|
|
26
21
|
* Sign a webhook payload using HMAC-SHA256
|
|
27
22
|
*
|
|
@@ -42,12 +37,12 @@ function signPayload(payload, secret, options = {}) {
|
|
|
42
37
|
: Math.floor(Date.now() / 1000);
|
|
43
38
|
|
|
44
39
|
// 1. Direct payload HMAC-SHA256 (compatible with GitHub / standard webhooks)
|
|
45
|
-
const hex =
|
|
40
|
+
const hex = hmacSha256(payloadStr, secret, 'hex');
|
|
46
41
|
const signature = `sha256=${hex}`;
|
|
47
42
|
|
|
48
43
|
// 2. Timestamped HMAC-SHA256 (compatible with Stripe / Svix / Slack)
|
|
49
44
|
const toSign = `${timestamp}.${payloadStr}`;
|
|
50
|
-
const timestampedHex =
|
|
45
|
+
const timestampedHex = hmacSha256(toSign, secret, 'hex');
|
|
51
46
|
const timestampedSignature = `t=${timestamp},v1=${timestampedHex}`;
|
|
52
47
|
|
|
53
48
|
return {
|
|
@@ -104,7 +99,7 @@ function verifySignature(payload, signatureHeader, secret, options = {}) {
|
|
|
104
99
|
}
|
|
105
100
|
|
|
106
101
|
const toSign = `${timestamp}.${payloadStr}`;
|
|
107
|
-
const expectedHex =
|
|
102
|
+
const expectedHex = hmacSha256(toSign, secret, 'hex');
|
|
108
103
|
|
|
109
104
|
return signatures.some((sig) => safeCompare(sig, expectedHex));
|
|
110
105
|
}
|
|
@@ -115,7 +110,7 @@ function verifySignature(payload, signatureHeader, secret, options = {}) {
|
|
|
115
110
|
receivedHex = receivedHex.slice(7);
|
|
116
111
|
}
|
|
117
112
|
|
|
118
|
-
const expectedHex =
|
|
113
|
+
const expectedHex = hmacSha256(payloadStr, secret, 'hex');
|
|
119
114
|
return safeCompare(receivedHex, expectedHex);
|
|
120
115
|
}
|
|
121
116
|
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
const EventEmitter = require('events');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
|
+
const { randomTimestampedId } = require('../../core/utils/crypto');
|
|
11
12
|
const { generateSecret } = require('./crypto');
|
|
12
13
|
const { matchesAnyEvent, normalizeEvents } = require('./matcher');
|
|
13
14
|
const { sendWebhook } = require('./sender');
|
|
15
|
+
const { safeJsonParse } = require('../../core/utils/json');
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Generate a standard unique delivery identifier
|
|
@@ -20,7 +22,7 @@ function generateDeliveryId() {
|
|
|
20
22
|
if (typeof crypto.randomUUID === 'function') {
|
|
21
23
|
return `dlv_${crypto.randomUUID().replace(/-/g, '')}`;
|
|
22
24
|
}
|
|
23
|
-
return
|
|
25
|
+
return randomTimestampedId('dlv', 8);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
class WebhookDispatcher extends EventEmitter {
|
|
@@ -47,7 +49,10 @@ class WebhookDispatcher extends EventEmitter {
|
|
|
47
49
|
this.deliveriesTable = options.deliveriesTable || 'webhook_deliveries';
|
|
48
50
|
this.maxAttempts = typeof options.maxAttempts === 'number' ? options.maxAttempts : 3;
|
|
49
51
|
this.backoffBaseMs = typeof options.backoffBaseMs === 'number' ? options.backoffBaseMs : 1000;
|
|
50
|
-
|
|
52
|
+
const defaultWebhookTimeout = options.timeouts?.webhook || 10000;
|
|
53
|
+
this.timeoutMs = typeof options.timeoutMs === 'number'
|
|
54
|
+
? options.timeoutMs
|
|
55
|
+
: (typeof options.timeout === 'number' ? options.timeout : defaultWebhookTimeout);
|
|
51
56
|
this.wrapPayload = options.wrapPayload !== undefined ? options.wrapPayload : true;
|
|
52
57
|
this.queue = options.queue || null;
|
|
53
58
|
this.logger = options.logger || console;
|
|
@@ -527,12 +532,7 @@ class WebhookDispatcher extends EventEmitter {
|
|
|
527
532
|
throw new Error(`Target endpoint #${delivery.endpoint_id} no longer exists.`);
|
|
528
533
|
}
|
|
529
534
|
|
|
530
|
-
|
|
531
|
-
try {
|
|
532
|
-
parsedPayload = JSON.parse(delivery.payload);
|
|
533
|
-
} catch (e) {
|
|
534
|
-
// Keep string if not json
|
|
535
|
-
}
|
|
535
|
+
const parsedPayload = safeJsonParse(delivery.payload, delivery.payload);
|
|
536
536
|
|
|
537
537
|
const nextAttempt = (delivery.attempt || 1) + 1;
|
|
538
538
|
return this.executeDelivery({
|