webspresso 0.0.98 → 0.0.99
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 +1 -1
- package/.agents/AUTH.md +169 -0
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +73 -5
- package/.agents/QUEUE.md +84 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +8 -0
- package/core/orm/model.js +4 -0
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +82 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/index.d.ts +528 -0
- package/index.js +44 -0
- package/package.json +1 -1
- 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/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/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +6 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/src/api/api-loader.js +1 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +137 -100
- package/src/helpers.js +9 -0
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
|
@@ -21,12 +21,34 @@ function createAuditLogHandlers(options) {
|
|
|
21
21
|
if (query.action) {
|
|
22
22
|
qb.where('action', String(query.action));
|
|
23
23
|
}
|
|
24
|
+
const recordId = query.recordId || query.resource_id || query.resourceId;
|
|
25
|
+
if (recordId !== undefined && recordId !== null && String(recordId).trim() !== '') {
|
|
26
|
+
qb.where('resource_id', String(recordId));
|
|
27
|
+
}
|
|
28
|
+
const actorId = query.actorId || query.actor_id;
|
|
29
|
+
if (actorId !== undefined && actorId !== null && String(actorId).trim() !== '') {
|
|
30
|
+
qb.where('actor_id', Number(actorId));
|
|
31
|
+
}
|
|
32
|
+
const actorEmail = query.actorEmail || query.actor_email;
|
|
33
|
+
if (actorEmail) {
|
|
34
|
+
qb.where('actor_email', 'like', `%${String(actorEmail).trim()}%`);
|
|
35
|
+
}
|
|
24
36
|
if (query.from) {
|
|
25
37
|
qb.where('created_at', '>=', String(query.from));
|
|
26
38
|
}
|
|
27
39
|
if (query.to) {
|
|
28
40
|
qb.where('created_at', '<=', String(query.to));
|
|
29
41
|
}
|
|
42
|
+
const searchTerm = query.search || query.q;
|
|
43
|
+
if (searchTerm && String(searchTerm).trim() !== '') {
|
|
44
|
+
const term = `%${String(searchTerm).trim()}%`;
|
|
45
|
+
qb.where((sub) => {
|
|
46
|
+
sub.where('actor_email', 'like', term)
|
|
47
|
+
.orWhere('path', 'like', term)
|
|
48
|
+
.orWhere('resource_id', 'like', term)
|
|
49
|
+
.orWhere('resource_model', 'like', term);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
30
52
|
}
|
|
31
53
|
|
|
32
54
|
async function listHandler(req, res) {
|
|
@@ -53,6 +75,79 @@ function createAuditLogHandlers(options) {
|
|
|
53
75
|
}
|
|
54
76
|
}
|
|
55
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Dedicated Record Timeline Handler
|
|
80
|
+
* GET /audit-logs/timeline/:model/:id or GET /audit-logs/timeline?model=Post&id=1
|
|
81
|
+
*/
|
|
82
|
+
async function timelineHandler(req, res) {
|
|
83
|
+
try {
|
|
84
|
+
const model = req.params?.model || req.query?.model;
|
|
85
|
+
const recordId = req.params?.id || req.query?.id || req.query?.recordId || req.query?.resource_id;
|
|
86
|
+
|
|
87
|
+
if (!model || recordId === undefined) {
|
|
88
|
+
return res.status(400).json({ error: 'model and id are required to fetch timeline' });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rows = await knex(tableName)
|
|
92
|
+
.where({
|
|
93
|
+
resource_model: String(model),
|
|
94
|
+
resource_id: String(recordId),
|
|
95
|
+
})
|
|
96
|
+
.orderBy('created_at', 'asc');
|
|
97
|
+
|
|
98
|
+
const totalChanges = rows.length;
|
|
99
|
+
const createdEvent = rows.find((r) => r.action === 'create') || null;
|
|
100
|
+
const lastModifiedEvent = rows.length > 0 ? rows[rows.length - 1] : null;
|
|
101
|
+
|
|
102
|
+
const events = rows.map((r, index) => {
|
|
103
|
+
let parsedMetadata = r.metadata;
|
|
104
|
+
if (typeof parsedMetadata === 'string') {
|
|
105
|
+
try {
|
|
106
|
+
parsedMetadata = JSON.parse(parsedMetadata);
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const changedFields = Array.isArray(parsedMetadata?.changedFields)
|
|
111
|
+
? parsedMetadata.changedFields
|
|
112
|
+
: [];
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
id: r.id,
|
|
116
|
+
action: r.action,
|
|
117
|
+
createdAt: r.created_at,
|
|
118
|
+
actor: {
|
|
119
|
+
id: r.actor_id,
|
|
120
|
+
email: r.actor_email,
|
|
121
|
+
},
|
|
122
|
+
resourceModel: r.resource_model,
|
|
123
|
+
resourceId: r.resource_id,
|
|
124
|
+
httpMethod: r.http_method,
|
|
125
|
+
path: r.path,
|
|
126
|
+
ip: r.ip,
|
|
127
|
+
userAgent: r.user_agent,
|
|
128
|
+
changedFields,
|
|
129
|
+
metadata: parsedMetadata,
|
|
130
|
+
index: index + 1,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
res.json({
|
|
135
|
+
model: String(model),
|
|
136
|
+
recordId: String(recordId),
|
|
137
|
+
summary: {
|
|
138
|
+
totalChanges,
|
|
139
|
+
createdAt: createdEvent?.created_at || (events[0] ? events[0].createdAt : null),
|
|
140
|
+
createdBy: createdEvent?.actor_email || (events[0] ? events[0].actor?.email : null),
|
|
141
|
+
lastModifiedAt: lastModifiedEvent?.created_at || null,
|
|
142
|
+
lastModifiedBy: lastModifiedEvent?.actor_email || null,
|
|
143
|
+
},
|
|
144
|
+
timeline: events,
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
res.status(500).json({ error: err.message });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
56
151
|
async function purgeHandler(req, res) {
|
|
57
152
|
try {
|
|
58
153
|
const body = req.body || {};
|
|
@@ -89,8 +184,10 @@ function createAuditLogHandlers(options) {
|
|
|
89
184
|
* @param {Object} [filters]
|
|
90
185
|
* @param {string} [filters.model]
|
|
91
186
|
* @param {string} [filters.action]
|
|
187
|
+
* @param {string} [filters.recordId]
|
|
92
188
|
* @param {string} [filters.from]
|
|
93
189
|
* @param {string} [filters.to]
|
|
190
|
+
* @param {string} [filters.search]
|
|
94
191
|
* @param {number} [filters.limit=100]
|
|
95
192
|
* @param {number} [filters.offset=0]
|
|
96
193
|
*/
|
|
@@ -105,10 +202,27 @@ function createAuditLogHandlers(options) {
|
|
|
105
202
|
.limit(limit);
|
|
106
203
|
}
|
|
107
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Programmatic query for single record timeline
|
|
207
|
+
* @param {string} model
|
|
208
|
+
* @param {string|number} recordId
|
|
209
|
+
*/
|
|
210
|
+
async function queryTimeline(model, recordId) {
|
|
211
|
+
if (!model || recordId === undefined) return [];
|
|
212
|
+
return knex(tableName)
|
|
213
|
+
.where({
|
|
214
|
+
resource_model: String(model),
|
|
215
|
+
resource_id: String(recordId),
|
|
216
|
+
})
|
|
217
|
+
.orderBy('created_at', 'asc');
|
|
218
|
+
}
|
|
219
|
+
|
|
108
220
|
return {
|
|
109
221
|
listHandler,
|
|
222
|
+
timelineHandler,
|
|
110
223
|
purgeHandler,
|
|
111
224
|
queryLogs,
|
|
225
|
+
queryTimeline,
|
|
112
226
|
};
|
|
113
227
|
}
|
|
114
228
|
|
|
@@ -36,6 +36,7 @@ function auditLogPlugin(options = {}) {
|
|
|
36
36
|
tableName = 'audit_logs',
|
|
37
37
|
includeDefaultPage = true,
|
|
38
38
|
apiPrefix = '/audit-logs',
|
|
39
|
+
registerRecordAction = true,
|
|
39
40
|
} = options;
|
|
40
41
|
|
|
41
42
|
const pageTitle = title || adminTitle || customPageTitle || 'Audit log';
|
|
@@ -152,6 +153,8 @@ function auditLogPlugin(options = {}) {
|
|
|
152
153
|
return { ...retentionConfig };
|
|
153
154
|
},
|
|
154
155
|
queryLogs: (filters) => handlers.queryLogs(filters),
|
|
156
|
+
queryTimeline: (model, recordId) => handlers.queryTimeline(model, recordId),
|
|
157
|
+
getRecordTimeline: (model, recordId) => handlers.queryTimeline(model, recordId),
|
|
155
158
|
getMigrationTemplate: (name) => generateAuditLogsMigration(name || tableName),
|
|
156
159
|
},
|
|
157
160
|
|
|
@@ -211,10 +214,31 @@ function auditLogPlugin(options = {}) {
|
|
|
211
214
|
prefix: apiPrefix,
|
|
212
215
|
routes: [
|
|
213
216
|
{ method: 'get', path: '', handler: handlers.listHandler },
|
|
217
|
+
{ method: 'get', path: '/timeline', handler: handlers.timelineHandler },
|
|
218
|
+
{ method: 'get', path: '/timeline/:model/:id', handler: handlers.timelineHandler },
|
|
214
219
|
{ method: 'post', path: '/purge', handler: handlers.purgeHandler },
|
|
215
220
|
],
|
|
216
221
|
},
|
|
217
222
|
});
|
|
223
|
+
|
|
224
|
+
if (registerRecordAction && typeof adminApi.registerAction === 'function') {
|
|
225
|
+
adminApi.registerAction('audit-timeline', {
|
|
226
|
+
id: 'audit-timeline',
|
|
227
|
+
label: 'Zaman Tüneli',
|
|
228
|
+
icon: 'clock',
|
|
229
|
+
color: 'blue',
|
|
230
|
+
models: '*',
|
|
231
|
+
handler: async (record, model) => {
|
|
232
|
+
const primaryKey = model?.primaryKey || 'id';
|
|
233
|
+
const id = record ? record[primaryKey] : null;
|
|
234
|
+
const modelName = model?.name || (typeof model === 'string' ? model : '');
|
|
235
|
+
return {
|
|
236
|
+
redirect: `/audit-log?model=${encodeURIComponent(modelName)}&recordId=${encodeURIComponent(id)}`,
|
|
237
|
+
message: `Zaman tüneli: ${modelName} #${id}`,
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
218
242
|
},
|
|
219
243
|
};
|
|
220
244
|
}
|
|
@@ -569,30 +569,46 @@ function generateFileManagerComponent(options = {}) {
|
|
|
569
569
|
var onSelect = opts.onSelect;
|
|
570
570
|
var accept = opts.accept || '';
|
|
571
571
|
|
|
572
|
+
var existing = document.getElementById('file-manager-picker-modal-root');
|
|
573
|
+
if (existing && existing.parentNode) {
|
|
574
|
+
m.mount(existing, null);
|
|
575
|
+
existing.parentNode.removeChild(existing);
|
|
576
|
+
}
|
|
577
|
+
|
|
572
578
|
var modalContainer = document.createElement('div');
|
|
573
579
|
modalContainer.id = 'file-manager-picker-modal-root';
|
|
574
580
|
document.body.appendChild(modalContainer);
|
|
575
581
|
|
|
576
582
|
function cleanup() {
|
|
583
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
577
584
|
if (modalContainer && modalContainer.parentNode) {
|
|
578
585
|
m.mount(modalContainer, null);
|
|
579
586
|
modalContainer.parentNode.removeChild(modalContainer);
|
|
580
587
|
}
|
|
581
588
|
}
|
|
582
589
|
|
|
590
|
+
function onKeyDown(e) {
|
|
591
|
+
if (e.key === 'Escape') cleanup();
|
|
592
|
+
}
|
|
593
|
+
document.addEventListener('keydown', onKeyDown);
|
|
594
|
+
|
|
583
595
|
var PickerModalComponent = {
|
|
584
596
|
view: function() {
|
|
585
|
-
return m('div.fixed.inset-0.bg-black/60.flex.items-center.justify-center.z-[9999].p-4.backdrop-blur-sm',
|
|
597
|
+
return m('div.fixed.inset-0.bg-black/60.flex.items-center.justify-center.z-[9999].p-4.backdrop-blur-sm', {
|
|
598
|
+
onclick: function(e) {
|
|
599
|
+
if (e.target === e.currentTarget) cleanup();
|
|
600
|
+
}
|
|
601
|
+
}, [
|
|
586
602
|
m('div.bg-white.dark:bg-slate-900.rounded-2xl.shadow-2xl.max-w-4xl.w-full.max-h-[90vh].flex.flex-col.overflow-hidden.border.border-gray-200.dark:border-slate-700', [
|
|
587
603
|
m('div.flex.items-center.justify-between.p-4.border-b.border-gray-200.dark:border-slate-700.bg-gray-50.dark:bg-slate-800', [
|
|
588
|
-
m('h2.text-base.font-bold.text-gray-900.dark:text-slate-100', opts.title || '
|
|
604
|
+
m('h2.text-base.font-bold.text-gray-900.dark:text-slate-100', opts.title || 'Choose from Media Library'),
|
|
589
605
|
m('button.text-gray-400.hover:text-gray-600.dark:hover:text-slate-200.text-xl', {
|
|
590
606
|
onclick: cleanup
|
|
591
607
|
}, '✕')
|
|
592
608
|
]),
|
|
593
609
|
m('div.p-4.overflow-y-auto.flex-1', [
|
|
594
|
-
m(FileManagerView(true, function(selectedUrl) {
|
|
595
|
-
if (onSelect) onSelect(selectedUrl);
|
|
610
|
+
m(FileManagerView(true, function(selectedUrl, item) {
|
|
611
|
+
if (onSelect) onSelect(selectedUrl, item);
|
|
596
612
|
cleanup();
|
|
597
613
|
}, accept))
|
|
598
614
|
])
|
|
@@ -92,6 +92,26 @@ function fileManagerPlugin(options = {}) {
|
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
const componentCode = generateFileManagerComponent({
|
|
96
|
+
apiPrefix,
|
|
97
|
+
publicBasePath,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Always configure fileManager in admin settings and register client component
|
|
101
|
+
// so window.__openFileManagerPicker is available across all admin pages (models, records, rich-text)
|
|
102
|
+
if (typeof adminApi.getRegistry === 'function') {
|
|
103
|
+
const registry = adminApi.getRegistry();
|
|
104
|
+
registry.configure({
|
|
105
|
+
fileManager: {
|
|
106
|
+
enabled: true,
|
|
107
|
+
apiPrefix,
|
|
108
|
+
publicBasePath,
|
|
109
|
+
pageTitle,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
registry.registerClientComponent('files', componentCode);
|
|
113
|
+
}
|
|
114
|
+
|
|
95
115
|
adminApi.registerModule({
|
|
96
116
|
id: 'files',
|
|
97
117
|
pages: includePage
|
|
@@ -100,10 +120,7 @@ function fileManagerPlugin(options = {}) {
|
|
|
100
120
|
title: pageTitle,
|
|
101
121
|
path: '/files',
|
|
102
122
|
icon: 'folder',
|
|
103
|
-
component:
|
|
104
|
-
apiPrefix,
|
|
105
|
-
publicBasePath,
|
|
106
|
-
}),
|
|
123
|
+
component: componentCode,
|
|
107
124
|
}]
|
|
108
125
|
: [],
|
|
109
126
|
|
package/plugins/index.js
CHANGED
|
@@ -53,6 +53,12 @@ module.exports = {
|
|
|
53
53
|
get xlsx() { return require('./xlsx').xlsx; },
|
|
54
54
|
get csvPlugin() { return require('./csv').csvPlugin; },
|
|
55
55
|
get csv() { return require('./csv').csv; },
|
|
56
|
+
get oauthPlugin() { return require('./oauth'); },
|
|
57
|
+
get OAuthClient() { return require('./oauth').OAuthClient; },
|
|
58
|
+
get OAuthError() { return require('./oauth').OAuthError; },
|
|
59
|
+
get createOAuthClient() { return require('./oauth').createOAuthClient; },
|
|
60
|
+
get apiKeyPlugin() { return require('./api-key').apiKeyPlugin; },
|
|
61
|
+
get createApiKeyMiddleware() { return require('./api-key').createApiKeyMiddleware; },
|
|
56
62
|
};
|
|
57
63
|
|
|
58
64
|
|
|
@@ -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) {
|
package/src/api/api-loader.js
CHANGED