webspresso 0.0.98 → 0.0.100
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/ADMIN.md +11 -1
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 & Social Login Plugin for Webspresso
|
|
3
|
+
* Zero external dependencies: powered by native node:crypto and global fetch
|
|
4
|
+
* RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), signed anti-CSRF state tokens, and dual auth integration
|
|
5
|
+
* @module plugins/oauth
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { OAuthClient, OAuthError, createOAuthClient } = require('../../core/auth/oauth');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve effective redirect URI for a provider
|
|
12
|
+
* Uses configured redirectUri or derives dynamically from request headers/host
|
|
13
|
+
*
|
|
14
|
+
* @param {import('express').Request} req
|
|
15
|
+
* @param {string} providerName
|
|
16
|
+
* @param {Object} providerConfig
|
|
17
|
+
* @param {string} pathPrefix
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function resolveRedirectUri(req, providerName, providerConfig, pathPrefix) {
|
|
21
|
+
if (providerConfig.redirectUri) {
|
|
22
|
+
return providerConfig.redirectUri;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const proto = req.headers['x-forwarded-proto'] || (req.connection?.encrypted ? 'https' : req.protocol) || 'http';
|
|
26
|
+
const host = req.headers['x-forwarded-host'] || req.headers.host;
|
|
27
|
+
const cleanPrefix = pathPrefix.startsWith('/') ? pathPrefix : `/${pathPrefix}`;
|
|
28
|
+
return `${proto}://${host}${cleanPrefix}/${providerName}/callback`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Webspresso OAuth Plugin Factory
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} [options]
|
|
35
|
+
* @param {OAuthClient} [options.client] - Optional pre-instantiated OAuthClient instance
|
|
36
|
+
* @param {Record<string, Object>} [options.providers] - Provider configurations (google, github, etc.)
|
|
37
|
+
* @param {string} [options.secret] - State token signing secret
|
|
38
|
+
* @param {string} [options.pathPrefix='/auth'] - Base path prefix for OAuth routes
|
|
39
|
+
* @param {boolean} [options.routes=true] - Mount automatic /:provider and /:provider/callback routes
|
|
40
|
+
* @param {boolean} [options.pkce=true] - Enable RFC 7636 PKCE
|
|
41
|
+
* @param {number} [options.stateTtlMs=600000] - State TTL in milliseconds (default 10 mins)
|
|
42
|
+
* @param {string} [options.defaultReturnTo='/'] - Default redirect URL after successful login
|
|
43
|
+
* @param {string} [options.failureRedirect='/login'] - Default redirect URL on failure
|
|
44
|
+
* @param {Function} [options.findOrCreateUser] - (params: { provider, profile, tokens, stateData, req, res, db }) => Promise<User>
|
|
45
|
+
* @param {Function} [options.onSuccess] - (params: { provider, user, profile, tokens, stateData, req, res, client }) => Promise<void|any>
|
|
46
|
+
* @param {Function} [options.onError] - (params: { error, provider, req, res, client }) => Promise<void|any>
|
|
47
|
+
* @param {Object} [options.db] - Optional Database instance
|
|
48
|
+
* @returns {Object} Webspresso Plugin Definition
|
|
49
|
+
*/
|
|
50
|
+
function oauthPlugin(options = {}) {
|
|
51
|
+
const {
|
|
52
|
+
pathPrefix = '/auth',
|
|
53
|
+
routes: mountRoutes = true,
|
|
54
|
+
pkce = true,
|
|
55
|
+
defaultReturnTo = '/',
|
|
56
|
+
failureRedirect = '/login',
|
|
57
|
+
stateTtlMs = 10 * 60 * 1000,
|
|
58
|
+
providers = {},
|
|
59
|
+
secret,
|
|
60
|
+
} = options;
|
|
61
|
+
|
|
62
|
+
const client = options.client || createOAuthClient({
|
|
63
|
+
secret: secret || process.env.OAUTH_SECRET || process.env.SESSION_SECRET || 'webspresso-oauth-secret',
|
|
64
|
+
stateTtlMs,
|
|
65
|
+
providers,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Handle OAuth failure response
|
|
70
|
+
*/
|
|
71
|
+
async function handleFailure(err, providerName, req, res, next) {
|
|
72
|
+
if (typeof options.onError === 'function') {
|
|
73
|
+
try {
|
|
74
|
+
const hookResult = await options.onError({
|
|
75
|
+
error: err,
|
|
76
|
+
provider: providerName,
|
|
77
|
+
req,
|
|
78
|
+
res,
|
|
79
|
+
client,
|
|
80
|
+
});
|
|
81
|
+
if (res.headersSent || hookResult === false) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
} catch (hookErr) {
|
|
85
|
+
console.error('[oauthPlugin] onError hook error:', hookErr);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const acceptsJson = req.xhr || (req.headers.accept && req.headers.accept.includes('application/json')) || req.query.format === 'json';
|
|
90
|
+
if (acceptsJson) {
|
|
91
|
+
return res.status(400).json({
|
|
92
|
+
error: err.code || 'OAUTH_ERROR',
|
|
93
|
+
message: err.message,
|
|
94
|
+
details: err.details || null,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const failureBase = options.failureRedirect || failureRedirect;
|
|
99
|
+
const url = new URL(failureBase, 'http://localhost');
|
|
100
|
+
url.searchParams.set('error', err.code || 'oauth_failed');
|
|
101
|
+
url.searchParams.set('message', err.message);
|
|
102
|
+
|
|
103
|
+
const redirectTarget = `${url.pathname}${url.search}`;
|
|
104
|
+
return res.redirect(redirectTarget);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handle OAuth success response
|
|
109
|
+
*/
|
|
110
|
+
async function handleSuccess(result, req, res, next) {
|
|
111
|
+
const { provider, user: profile, tokens, stateData } = result;
|
|
112
|
+
|
|
113
|
+
let appUser = null;
|
|
114
|
+
const db = req.db || options.db;
|
|
115
|
+
if (typeof options.findOrCreateUser === 'function') {
|
|
116
|
+
appUser = await options.findOrCreateUser({
|
|
117
|
+
provider,
|
|
118
|
+
profile,
|
|
119
|
+
tokens,
|
|
120
|
+
stateData,
|
|
121
|
+
req,
|
|
122
|
+
res,
|
|
123
|
+
db,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const effectiveUser = appUser || profile;
|
|
128
|
+
|
|
129
|
+
if (typeof options.onSuccess === 'function') {
|
|
130
|
+
const hookResult = await options.onSuccess({
|
|
131
|
+
provider,
|
|
132
|
+
user: effectiveUser,
|
|
133
|
+
profile,
|
|
134
|
+
tokens,
|
|
135
|
+
stateData,
|
|
136
|
+
req,
|
|
137
|
+
res,
|
|
138
|
+
client,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (res.headersSent || hookResult === false) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Default Session / Dual-auth login integration
|
|
147
|
+
if (effectiveUser) {
|
|
148
|
+
req.user = effectiveUser;
|
|
149
|
+
if (req.auth && typeof req.auth.login === 'function') {
|
|
150
|
+
try {
|
|
151
|
+
await req.auth.login(effectiveUser);
|
|
152
|
+
} catch (authErr) {
|
|
153
|
+
console.warn('[oauthPlugin] req.auth.login failed:', authErr.message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// JSON response for API/mobile clients
|
|
159
|
+
const acceptsJson = req.xhr || (req.headers.accept && req.headers.accept.includes('application/json')) || req.query.format === 'json';
|
|
160
|
+
if (acceptsJson) {
|
|
161
|
+
let jwtToken = undefined;
|
|
162
|
+
if (req.auth && typeof req.auth.generateUserToken === 'function' && effectiveUser) {
|
|
163
|
+
try {
|
|
164
|
+
jwtToken = req.auth.generateUserToken(effectiveUser);
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return res.json({
|
|
169
|
+
success: true,
|
|
170
|
+
provider,
|
|
171
|
+
user: effectiveUser,
|
|
172
|
+
token: jwtToken,
|
|
173
|
+
tokens,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const targetUrl = stateData?.returnTo || defaultReturnTo;
|
|
178
|
+
return res.redirect(targetUrl);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
name: 'oauth',
|
|
183
|
+
version: '1.0.0',
|
|
184
|
+
description: 'Zero-dependency OAuth 2.0 & Social Login plugin for Webspresso',
|
|
185
|
+
|
|
186
|
+
api: {
|
|
187
|
+
client,
|
|
188
|
+
getProvider: (name) => client.getProvider(name),
|
|
189
|
+
getProviderNames: () => client.getProviderNames(),
|
|
190
|
+
getAuthorizationUrl: (provider, opts) => client.getAuthorizationUrl(provider, opts),
|
|
191
|
+
exchangeCode: (provider, params) => client.exchangeCode(provider, params),
|
|
192
|
+
fetchUserProfile: (provider, tokens) => client.fetchUserProfile(provider, tokens),
|
|
193
|
+
handleCallback: (provider, params) => client.handleCallback(provider, params),
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
register(ctx) {
|
|
197
|
+
// 1. Attach req.oauth and helper methods to every incoming request
|
|
198
|
+
ctx.app.use((req, res, next) => {
|
|
199
|
+
req.oauth = client;
|
|
200
|
+
|
|
201
|
+
req.getOAuthAuthorizationUrl = (providerName, opts = {}) => {
|
|
202
|
+
const providerConfig = client.getProvider(providerName);
|
|
203
|
+
const redirectUri = opts.redirectUri || resolveRedirectUri(req, providerName, providerConfig, pathPrefix);
|
|
204
|
+
return client.getAuthorizationUrl(providerName, {
|
|
205
|
+
redirectUri,
|
|
206
|
+
pkce,
|
|
207
|
+
...opts,
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
req.handleOAuthCallback = (providerName, params = {}) => {
|
|
212
|
+
const providerConfig = client.getProvider(providerName);
|
|
213
|
+
const redirectUri = params.redirectUri || resolveRedirectUri(req, providerName, providerConfig, pathPrefix);
|
|
214
|
+
return client.handleCallback(providerName, {
|
|
215
|
+
redirectUri,
|
|
216
|
+
...params,
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
next();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// 2. Register named route middleware: middleware: ['oauth']
|
|
224
|
+
if (ctx.middlewares) {
|
|
225
|
+
ctx.middlewares.oauth = (providerName) => async (req, res, next) => {
|
|
226
|
+
req.oauthProvider = providerName;
|
|
227
|
+
next();
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 3. Register template helper: fsy.oauthUrl('google', { returnTo: '/profile' })
|
|
232
|
+
ctx.addHelper('oauthUrl', (providerName, helperOpts = {}) => {
|
|
233
|
+
const cleanPrefix = pathPrefix.startsWith('/') ? pathPrefix : `/${pathPrefix}`;
|
|
234
|
+
const base = `${cleanPrefix}/${providerName}`;
|
|
235
|
+
const params = new URLSearchParams();
|
|
236
|
+
if (helperOpts.returnTo) params.set('returnTo', helperOpts.returnTo);
|
|
237
|
+
if (helperOpts.scope) {
|
|
238
|
+
params.set('scope', Array.isArray(helperOpts.scope) ? helperOpts.scope.join(' ') : helperOpts.scope);
|
|
239
|
+
}
|
|
240
|
+
const qs = params.toString();
|
|
241
|
+
return qs ? `${base}?${qs}` : base;
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
onRoutesReady(ctx) {
|
|
246
|
+
if (!mountRoutes) return;
|
|
247
|
+
|
|
248
|
+
const cleanPrefix = pathPrefix.startsWith('/') ? pathPrefix : `/${pathPrefix}`;
|
|
249
|
+
|
|
250
|
+
// Initiation Route: GET /auth/:provider
|
|
251
|
+
ctx.addRoute('get', `${cleanPrefix}/:provider`, async (req, res, next) => {
|
|
252
|
+
const providerName = req.params.provider;
|
|
253
|
+
if (!client.getProviderNames().includes(providerName)) {
|
|
254
|
+
return next();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const providerConfig = client.getProvider(providerName);
|
|
259
|
+
const redirectUri = resolveRedirectUri(req, providerName, providerConfig, pathPrefix);
|
|
260
|
+
|
|
261
|
+
const returnTo = req.query.returnTo || req.query.redirect || defaultReturnTo;
|
|
262
|
+
const extraState = req.query.state ? { customState: req.query.state } : {};
|
|
263
|
+
const stateData = {
|
|
264
|
+
returnTo,
|
|
265
|
+
...extraState,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const scopes = req.query.scope ? req.query.scope.split(/[\s,]+/) : undefined;
|
|
269
|
+
|
|
270
|
+
const { url } = client.getAuthorizationUrl(providerName, {
|
|
271
|
+
redirectUri,
|
|
272
|
+
scopes,
|
|
273
|
+
stateData,
|
|
274
|
+
pkce,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return res.redirect(url);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
return handleFailure(err, providerName, req, res, next);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Callback Route: GET /auth/:provider/callback
|
|
284
|
+
ctx.addRoute('get', `${cleanPrefix}/:provider/callback`, async (req, res, next) => {
|
|
285
|
+
const providerName = req.params.provider;
|
|
286
|
+
if (!client.getProviderNames().includes(providerName)) {
|
|
287
|
+
return next();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Provider reported error (e.g. user cancelled)
|
|
291
|
+
if (req.query.error) {
|
|
292
|
+
const errorMsg = req.query.error_description || req.query.error;
|
|
293
|
+
const oauthErr = new OAuthError(
|
|
294
|
+
`OAuth error from ${providerName}: ${errorMsg}`,
|
|
295
|
+
req.query.error,
|
|
296
|
+
req.query
|
|
297
|
+
);
|
|
298
|
+
return handleFailure(oauthErr, providerName, req, res, next);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const { code, state } = req.query;
|
|
302
|
+
if (!code || !state) {
|
|
303
|
+
const missingErr = new OAuthError(
|
|
304
|
+
'Missing OAuth authorization code or state parameter',
|
|
305
|
+
'INVALID_CALLBACK'
|
|
306
|
+
);
|
|
307
|
+
return handleFailure(missingErr, providerName, req, res, next);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const providerConfig = client.getProvider(providerName);
|
|
312
|
+
const redirectUri = resolveRedirectUri(req, providerName, providerConfig, pathPrefix);
|
|
313
|
+
|
|
314
|
+
const result = await client.handleCallback(providerName, {
|
|
315
|
+
code,
|
|
316
|
+
state,
|
|
317
|
+
redirectUri,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return handleSuccess(result, req, res, next);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
return handleFailure(err, providerName, req, res, next);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
oauthPlugin.oauthPlugin = oauthPlugin;
|
|
330
|
+
oauthPlugin.OAuthClient = OAuthClient;
|
|
331
|
+
oauthPlugin.OAuthError = OAuthError;
|
|
332
|
+
oauthPlugin.createOAuthClient = createOAuthClient;
|
|
333
|
+
|
|
334
|
+
module.exports = oauthPlugin;
|
|
335
|
+
module.exports.oauthPlugin = oauthPlugin;
|
|
336
|
+
module.exports.OAuthClient = OAuthClient;
|
|
337
|
+
module.exports.OAuthError = OAuthError;
|
|
338
|
+
module.exports.createOAuthClient = createOAuthClient;
|
package/plugins/queue/index.js
CHANGED
|
@@ -99,6 +99,10 @@ function queuePlugin(options = {}) {
|
|
|
99
99
|
api: {
|
|
100
100
|
getQueueManager: () => queueManager,
|
|
101
101
|
clean: (opts) => queueManager?.clean(opts),
|
|
102
|
+
schedule: (expr, name, data, opts) => queueManager?.schedule(expr, name, data, opts),
|
|
103
|
+
cron: (expr, name, data, opts) => queueManager?.cron(expr, name, data, opts),
|
|
104
|
+
unschedule: (idOrName) => queueManager?.unschedule(idOrName),
|
|
105
|
+
getSchedules: () => queueManager?.getSchedules() || [],
|
|
102
106
|
},
|
|
103
107
|
|
|
104
108
|
onRoutesReady(ctx) {
|