webspresso 0.0.105 → 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 +15 -2
- package/.agents/CLI.md +219 -7
- package/.agents/FLAGS.md +118 -0
- package/.agents/LOGGING.md +93 -0
- package/.agents/METRICS.md +127 -0
- package/.agents/MIGRATION_JOBS.md +95 -0
- package/.agents/PLUGINS.md +10 -3
- package/.agents/QUEUE.md +42 -0
- package/.agents/RATE_LIMIT.md +147 -0
- package/.agents/SCALE_OUT.md +109 -0
- package/.agents/SECRETS.md +117 -0
- package/AGENTS.md +10 -0
- 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-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/export.js +87 -0
- package/bin/commands/secrets-pull.js +208 -0
- package/bin/utils/db.js +7 -1
- package/bin/webspresso.js +22 -0
- package/core/app.js +421 -0
- package/core/auth/authenticate.js +93 -0
- package/core/auth/index.js +34 -4
- package/core/auth/manager.js +50 -5
- package/core/auth/middleware.js +16 -68
- package/core/auth/redis-session.js +1008 -0
- package/core/auth/sessions.js +156 -0
- package/core/cache/cdn.js +296 -0
- package/core/cache/index.js +12 -0
- package/core/cache/tagged-lru.js +9 -1
- 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/errors/domain.js +1 -0
- package/core/events/dispatcher.js +7 -0
- 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/middleware.js +3 -3
- 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/middleware.js +9 -1
- 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/d1.js +265 -0
- package/core/orm/index.js +105 -5
- package/core/orm/migrate-cloud.js +194 -0
- package/core/orm/pool.js +97 -0
- package/core/orm/query-builder.js +9 -0
- package/core/queue/adapters/cloudflare.js +131 -0
- 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 +11 -0
- 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 +31 -0
- 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/shutdown-manager.js +9 -0
- 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/index.d.ts +934 -2
- package/index.js +125 -3
- package/package.json +3 -1
- package/plugins/admin-panel/index.js +12 -5
- package/plugins/api-key/index.js +3 -2
- package/plugins/audit-log/index.js +3 -1
- package/plugins/basic-auth/index.js +3 -2
- package/plugins/cors.js +3 -2
- package/plugins/csrf/index.js +4 -3
- package/plugins/csv/index.js +8 -5
- package/plugins/email/index.js +11 -9
- package/plugins/index.js +3 -0
- package/plugins/queue/index.js +18 -2
- package/plugins/redirect/index.js +7 -2
- package/plugins/seo-checker/index.js +2 -2
- package/plugins/site-analytics/index.js +3 -1
- package/plugins/swagger.js +8 -2
- package/plugins/upload/gcs-provider.js +148 -0
- package/plugins/upload/index.js +132 -74
- package/plugins/upload/s3-provider.js +257 -0
- package/plugins/webhooks/index.js +27 -23
- 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/client-runtime/constants.js +10 -0
- package/src/client-runtime/mount.js +2 -3
- package/src/dev/client-gen.js +458 -0
- package/src/discovery/scan-routes.js +5 -2
- package/src/file-router.js +2 -0
- package/src/helpers.js +53 -0
- package/src/pages/page-loader.js +12 -205
- package/src/plugin-manager.js +9 -1
- package/src/server.js +231 -140
- package/src/services/executor.js +9 -0
- package/src/services/registry.js +9 -1
- package/src/services/validator.js +38 -20
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Cloud Storage (GCS) File Provider for Webspresso
|
|
3
|
+
* Enables direct file storage, streaming, and signed upload URLs with Google Cloud Storage
|
|
4
|
+
* Zero-dependency compatible: supports official @google-cloud/storage or standard duck-typed client interfaces
|
|
5
|
+
* @module plugins/upload/gcs-provider
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { generateStorageKey } = require('./s3-provider');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Google Cloud Storage Provider
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {Object|string} options.bucket - GCS Bucket name (string) or Bucket instance (e.g. storage.bucket('my-bucket'))
|
|
16
|
+
* @param {Object} [options.storageClient] - Optional Storage instance from @google-cloud/storage
|
|
17
|
+
* @param {string} [options.publicBaseUrl] - Custom public domain or CDN URL (e.g. 'https://cdn.example.com')
|
|
18
|
+
* @param {string} [options.prefix='uploads'] - Subdirectory prefix inside the GCS bucket
|
|
19
|
+
* @returns {Object} Upload storage provider implementing put, delete, and createSignedUploadUrl
|
|
20
|
+
*/
|
|
21
|
+
function createGcsFileProvider(options = {}) {
|
|
22
|
+
const { storageClient, publicBaseUrl, prefix = 'uploads' } = options;
|
|
23
|
+
let bucket = options.bucket;
|
|
24
|
+
|
|
25
|
+
if (!bucket) {
|
|
26
|
+
throw new Error('[createGcsFileProvider] GCS Bucket (name string or Bucket object) is required.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const bucketName = typeof bucket === 'string' ? bucket : (bucket.name || 'bucket');
|
|
30
|
+
let bucketInstance = typeof bucket === 'object' && bucket !== null ? bucket : null;
|
|
31
|
+
|
|
32
|
+
if (typeof bucket === 'string' && storageClient && typeof storageClient.bucket === 'function') {
|
|
33
|
+
bucketInstance = storageClient.bucket(bucket);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const defaultBaseUrl = `https://storage.googleapis.com/${bucketName}`;
|
|
37
|
+
const normalizedBaseUrl = (publicBaseUrl || defaultBaseUrl).replace(/\/+$/, '');
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
bucketName,
|
|
41
|
+
publicBaseUrl: normalizedBaseUrl,
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Store file in Google Cloud Storage
|
|
45
|
+
* @param {Object} args
|
|
46
|
+
* @param {Buffer} args.buffer
|
|
47
|
+
* @param {string} args.originalName
|
|
48
|
+
* @param {string} args.mimeType
|
|
49
|
+
* @returns {Promise<{ publicUrl: string, key: string }>}
|
|
50
|
+
*/
|
|
51
|
+
async put({ buffer, originalName, mimeType }) {
|
|
52
|
+
const key = generateStorageKey(originalName, mimeType, prefix);
|
|
53
|
+
|
|
54
|
+
if (bucketInstance && typeof bucketInstance.file === 'function') {
|
|
55
|
+
const file = bucketInstance.file(key);
|
|
56
|
+
await file.save(buffer, {
|
|
57
|
+
metadata: {
|
|
58
|
+
contentType: mimeType || 'application/octet-stream',
|
|
59
|
+
metadata: {
|
|
60
|
+
originalName: originalName || '',
|
|
61
|
+
uploadedAt: new Date().toISOString(),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
resumable: false,
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'[createGcsFileProvider] A valid GCS bucket instance with .file() is required to perform .put().'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const publicUrl = `${normalizedBaseUrl}/${key}`;
|
|
73
|
+
return { publicUrl, key };
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Delete file from Google Cloud Storage
|
|
78
|
+
* @param {string} key
|
|
79
|
+
* @returns {Promise<boolean>}
|
|
80
|
+
*/
|
|
81
|
+
async delete(key) {
|
|
82
|
+
if (bucketInstance && typeof bucketInstance.file === 'function') {
|
|
83
|
+
const file = bucketInstance.file(key);
|
|
84
|
+
try {
|
|
85
|
+
if (typeof file.delete === 'function') {
|
|
86
|
+
await file.delete({ ignoreNotFound: true });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
} catch (_) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Generate a V4 signed URL for direct browser-to-GCS upload
|
|
98
|
+
* @param {Object} [params]
|
|
99
|
+
* @param {string} [params.originalName]
|
|
100
|
+
* @param {string} [params.mimeType]
|
|
101
|
+
* @param {number} [params.expiresInMs=900000] - 15 minutes default
|
|
102
|
+
* @param {string} [params.key] - Custom key override
|
|
103
|
+
* @returns {Promise<{ uploadUrl: string, publicUrl: string, key: string, method: string, headers: Record<string, string> }>}
|
|
104
|
+
*/
|
|
105
|
+
async createSignedUploadUrl(params = {}) {
|
|
106
|
+
const {
|
|
107
|
+
originalName = 'upload',
|
|
108
|
+
mimeType = 'application/octet-stream',
|
|
109
|
+
expiresInMs = 15 * 60 * 1000,
|
|
110
|
+
key: customKey,
|
|
111
|
+
} = params;
|
|
112
|
+
|
|
113
|
+
const key = customKey || generateStorageKey(originalName, mimeType, prefix);
|
|
114
|
+
|
|
115
|
+
if (!bucketInstance || typeof bucketInstance.file !== 'function') {
|
|
116
|
+
throw new Error('[createGcsFileProvider] GCS bucket instance required for generating signed upload URLs.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const file = bucketInstance.file(key);
|
|
120
|
+
if (typeof file.getSignedUrl !== 'function') {
|
|
121
|
+
throw new Error('[createGcsFileProvider] file.getSignedUrl() method is not implemented on GCS File instance.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const [signedUrl] = await file.getSignedUrl({
|
|
125
|
+
version: 'v4',
|
|
126
|
+
action: 'write',
|
|
127
|
+
expires: Date.now() + expiresInMs,
|
|
128
|
+
contentType: mimeType,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const publicUrl = `${normalizedBaseUrl}/${key}`;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
uploadUrl: signedUrl,
|
|
135
|
+
publicUrl,
|
|
136
|
+
key,
|
|
137
|
+
method: 'PUT',
|
|
138
|
+
headers: {
|
|
139
|
+
'Content-Type': mimeType,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
createGcsFileProvider,
|
|
148
|
+
};
|
package/plugins/upload/index.js
CHANGED
|
@@ -122,6 +122,122 @@ function uploadPlugin(options = {}) {
|
|
|
122
122
|
const mw = ensureArray(middleware).filter(Boolean);
|
|
123
123
|
const parseMultipart = createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles);
|
|
124
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
|
+
|
|
125
241
|
return {
|
|
126
242
|
name: 'upload',
|
|
127
243
|
version: '1.0.0',
|
|
@@ -160,83 +276,13 @@ function uploadPlugin(options = {}) {
|
|
|
160
276
|
}
|
|
161
277
|
}
|
|
162
278
|
}
|
|
279
|
+
if (typeof ctx.addRoute === 'function' && !ctx.core) {
|
|
280
|
+
mountRoutes(ctx);
|
|
281
|
+
}
|
|
163
282
|
},
|
|
164
283
|
|
|
165
284
|
onRoutesReady(ctx) {
|
|
166
|
-
|
|
167
|
-
app.set('webspresso.uploadPath', normalizedPath);
|
|
168
|
-
|
|
169
|
-
const handler = (req, res) => {
|
|
170
|
-
parseMultipart(req, res, async (err) => {
|
|
171
|
-
if (err) {
|
|
172
|
-
const { status, message } = multerErrorResponse(err);
|
|
173
|
-
return res.status(status).json({ error: message, message });
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const files = multiple ? (req.files || []) : (req.file ? [req.file] : []);
|
|
177
|
-
if (files.length === 0) {
|
|
178
|
-
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Validate all files first
|
|
182
|
-
for (const file of files) {
|
|
183
|
-
if (!file || !file.buffer) {
|
|
184
|
-
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const mime = file.mimetype || 'application/octet-stream';
|
|
188
|
-
const originalName = file.originalname || 'upload';
|
|
189
|
-
const ext = extensionFromName(originalName);
|
|
190
|
-
|
|
191
|
-
if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
|
|
192
|
-
return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
|
|
193
|
-
}
|
|
194
|
-
if (
|
|
195
|
-
extensionAllowlist &&
|
|
196
|
-
extensionAllowlist.length &&
|
|
197
|
-
(!ext || !extensionAllowlist.includes(ext))
|
|
198
|
-
) {
|
|
199
|
-
return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
const uploadPromises = files.map(async (file) => {
|
|
205
|
-
const mime = file.mimetype || 'application/octet-stream';
|
|
206
|
-
const originalName = file.originalname || 'upload';
|
|
207
|
-
const result = await provider.put({
|
|
208
|
-
buffer: file.buffer,
|
|
209
|
-
originalName,
|
|
210
|
-
mimeType: mime,
|
|
211
|
-
size: file.size,
|
|
212
|
-
req,
|
|
213
|
-
});
|
|
214
|
-
return {
|
|
215
|
-
url: result.publicUrl,
|
|
216
|
-
publicUrl: result.publicUrl,
|
|
217
|
-
key: result.key,
|
|
218
|
-
};
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
const results = await Promise.all(uploadPromises);
|
|
222
|
-
|
|
223
|
-
if (multiple) {
|
|
224
|
-
res.json(results);
|
|
225
|
-
} else {
|
|
226
|
-
res.json(results[0]);
|
|
227
|
-
}
|
|
228
|
-
} catch (e) {
|
|
229
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
230
|
-
res.status(500).json({ error: message, message });
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
ctx.addRoute('post', normalizedPath, ...mw, handler);
|
|
236
|
-
|
|
237
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
238
|
-
console.log(` Upload plugin: POST ${normalizedPath}`);
|
|
239
|
-
}
|
|
285
|
+
mountRoutes(ctx);
|
|
240
286
|
},
|
|
241
287
|
};
|
|
242
288
|
}
|
|
@@ -245,10 +291,22 @@ const {
|
|
|
245
291
|
scanOrphanedUploads,
|
|
246
292
|
pruneOrphanedUploads,
|
|
247
293
|
} = require('./orphan-pruner');
|
|
294
|
+
const {
|
|
295
|
+
createR2FileProvider,
|
|
296
|
+
createS3FileProvider,
|
|
297
|
+
generateStorageKey,
|
|
298
|
+
} = require('./s3-provider');
|
|
299
|
+
const {
|
|
300
|
+
createGcsFileProvider,
|
|
301
|
+
} = require('./gcs-provider');
|
|
248
302
|
|
|
249
303
|
module.exports = {
|
|
250
304
|
uploadPlugin,
|
|
251
305
|
createLocalFileProvider,
|
|
306
|
+
createR2FileProvider,
|
|
307
|
+
createS3FileProvider,
|
|
308
|
+
createGcsFileProvider,
|
|
309
|
+
generateStorageKey,
|
|
252
310
|
scanOrphanedUploads,
|
|
253
311
|
pruneOrphanedUploads,
|
|
254
312
|
};
|
|
@@ -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
|
+
};
|