webspresso 0.0.83 → 0.0.85
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/README.md +84 -3
- package/bin/commands/orm-map.js +1 -1
- package/core/auth/index.js +10 -42
- package/core/auth/jwt.js +235 -0
- package/core/auth/manager.js +193 -0
- package/core/auth/middleware.js +66 -4
- package/index.d.ts +22 -0
- package/package.json +3 -3
- package/plugins/admin-panel/api.js +73 -0
- package/plugins/admin-panel/app.js +31 -13
- package/plugins/admin-panel/core/admin-module.js +75 -3
- package/plugins/admin-panel/core/registry.js +68 -0
- package/plugins/admin-panel/index.js +23 -4
- package/plugins/admin-panel/modules/custom-pages.js +40 -1
- package/plugins/admin-panel/modules/menu.js +23 -11
- package/plugins/admin-panel/modules/profile.js +192 -0
- package/plugins/content/client/inline-edit.js +33 -1
- package/plugins/cors.js +171 -0
- package/plugins/csrf/index.js +8 -1
- package/plugins/index.js +2 -0
- package/src/server.js +4 -2
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +16 -8
|
@@ -128,7 +128,15 @@ function createCustomPageApiHandlers(options) {
|
|
|
128
128
|
return res.status(404).json({ error: 'Action not found' });
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const
|
|
131
|
+
const payload = { ...(req.query || {}), ...(req.body || {}) };
|
|
132
|
+
const result = await action({
|
|
133
|
+
db,
|
|
134
|
+
req,
|
|
135
|
+
body: payload,
|
|
136
|
+
payload,
|
|
137
|
+
query: req.query || {},
|
|
138
|
+
user: req.session?.adminUser,
|
|
139
|
+
});
|
|
132
140
|
res.json({ success: true, result });
|
|
133
141
|
} catch (error) {
|
|
134
142
|
res.status(500).json({ error: error.message });
|
|
@@ -148,6 +156,37 @@ function generateCustomPageComponent() {
|
|
|
148
156
|
return `
|
|
149
157
|
// Custom Page Component Factory
|
|
150
158
|
function createCustomPage(pageConfig) {
|
|
159
|
+
if (pageConfig && (pageConfig.url || pageConfig.html)) {
|
|
160
|
+
var iframeAttrs = {
|
|
161
|
+
src: pageConfig.url || undefined,
|
|
162
|
+
srcdoc: pageConfig.html || undefined,
|
|
163
|
+
style: 'width: 100%; height: ' + (pageConfig.layout === false ? '100vh' : 'calc(100vh - 12rem)') + '; min-height: 600px; border: 0; border-radius: 0.5rem;',
|
|
164
|
+
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen',
|
|
165
|
+
allowfullscreen: true
|
|
166
|
+
};
|
|
167
|
+
if (pageConfig.layout === false) {
|
|
168
|
+
return {
|
|
169
|
+
view: function() {
|
|
170
|
+
return m('iframe', iframeAttrs);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
view: function() {
|
|
176
|
+
return m(Layout, [
|
|
177
|
+
m(Breadcrumb, { items: [{ label: pageConfig.title, href: pageConfig.path }] }),
|
|
178
|
+
m('div.mb-4.flex.items-start.justify-between.gap-4', [
|
|
179
|
+
m('div', [
|
|
180
|
+
m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', pageConfig.title),
|
|
181
|
+
pageConfig.description && m('p.text-gray-500.dark:text-slate-400.mt-1', pageConfig.description)
|
|
182
|
+
])
|
|
183
|
+
]),
|
|
184
|
+
m('iframe', iframeAttrs)
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
151
190
|
return {
|
|
152
191
|
oninit(vnode) {
|
|
153
192
|
vnode.state.data = null;
|
|
@@ -200,9 +200,11 @@ const ThemeToggle = {
|
|
|
200
200
|
const MenuItem = {
|
|
201
201
|
view(vnode) {
|
|
202
202
|
const { item, active } = vnode.attrs;
|
|
203
|
+
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
204
|
+
const href = item.path ? (item.path.startsWith('/') ? adminPath + item.path : adminPath + '/' + item.path) : '#';
|
|
203
205
|
|
|
204
206
|
return m('a.flex.items-center.gap-3.px-3.py-2.rounded-lg.text-sm.font-medium.transition-colors', {
|
|
205
|
-
href:
|
|
207
|
+
href: href,
|
|
206
208
|
class: active
|
|
207
209
|
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
|
|
208
210
|
: 'text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700',
|
|
@@ -210,6 +212,7 @@ const MenuItem = {
|
|
|
210
212
|
e.preventDefault();
|
|
211
213
|
sidebarOpen = false;
|
|
212
214
|
m.route.set(item.path);
|
|
215
|
+
setTimeout(() => m.redraw(), 0);
|
|
213
216
|
},
|
|
214
217
|
}, [
|
|
215
218
|
item.icon && m(Icon, { name: item.icon, class: 'w-5 h-5 flex-shrink-0' }),
|
|
@@ -336,16 +339,25 @@ const Sidebar = {
|
|
|
336
339
|
// User section
|
|
337
340
|
state.user && m('div.p-4.border-t.border-gray-200.dark:border-slate-600', [
|
|
338
341
|
m('div.flex.items-center.gap-3', [
|
|
339
|
-
m('div.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
342
|
+
m('div.flex-1.flex.items-center.gap-3.min-w-0.cursor-pointer.hover:opacity-80.transition-opacity', {
|
|
343
|
+
title: 'Edit Profile',
|
|
344
|
+
onclick: () => { sidebarOpen = false; m.route.set('/profile'); },
|
|
345
|
+
}, [
|
|
346
|
+
m('div.w-8.h-8.bg-blue-100.dark:bg-blue-900/50.rounded-full.flex.items-center.justify-center.flex-shrink-0', [
|
|
347
|
+
m('span.text-sm.font-semibold.text-blue-600.dark:text-blue-300',
|
|
348
|
+
(state.user.name || state.user.email || 'A').charAt(0).toUpperCase()
|
|
349
|
+
),
|
|
350
|
+
]),
|
|
351
|
+
m('div.flex-1.min-w-0', [
|
|
352
|
+
m('p.text-sm.font-medium.text-gray-900.dark:text-slate-100.truncate', state.user.name || 'Admin'),
|
|
353
|
+
m('p.text-xs.text-gray-500.dark:text-slate-400.truncate', state.user.email),
|
|
354
|
+
]),
|
|
347
355
|
]),
|
|
348
|
-
m('button.p-1.text-gray-400
|
|
356
|
+
m('button.p-1.text-gray-400.dark:text-slate-500.hover:text-blue-600.dark:hover:text-blue-400.transition-colors', {
|
|
357
|
+
title: 'My Profile',
|
|
358
|
+
onclick: () => { sidebarOpen = false; m.route.set('/profile'); },
|
|
359
|
+
}, m(Icon, { name: 'user', class: 'w-4 h-4' })),
|
|
360
|
+
m('button.p-1.text-gray-400.dark:text-slate-500.hover:text-red-600.dark:hover:text-red-400.transition-colors', {
|
|
349
361
|
title: 'Logout',
|
|
350
362
|
onclick: async () => {
|
|
351
363
|
await api.post('/auth/logout');
|
|
@@ -354,7 +366,7 @@ const Sidebar = {
|
|
|
354
366
|
clearIntendedRoute();
|
|
355
367
|
m.route.set('/login');
|
|
356
368
|
},
|
|
357
|
-
}, m(Icon, { name: 'logout', class: 'w-
|
|
369
|
+
}, m(Icon, { name: 'logout', class: 'w-4 h-4' })),
|
|
358
370
|
]),
|
|
359
371
|
]),
|
|
360
372
|
]),
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile Page Component
|
|
3
|
+
* Allows logged-in admin user to view and update name, email, and password
|
|
4
|
+
* @module plugins/admin-panel/modules/profile
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
function generateProfileComponent() {
|
|
8
|
+
return `
|
|
9
|
+
const ProfilePage = {
|
|
10
|
+
oninit(vnode) {
|
|
11
|
+
vnode.state.loading = true;
|
|
12
|
+
vnode.state.saving = false;
|
|
13
|
+
vnode.state.name = '';
|
|
14
|
+
vnode.state.email = '';
|
|
15
|
+
vnode.state.currentPassword = '';
|
|
16
|
+
vnode.state.newPassword = '';
|
|
17
|
+
vnode.state.confirmPassword = '';
|
|
18
|
+
vnode.state.error = null;
|
|
19
|
+
vnode.state.success = null;
|
|
20
|
+
|
|
21
|
+
api.get('/auth/me')
|
|
22
|
+
.then(res => {
|
|
23
|
+
if (res && res.user) {
|
|
24
|
+
vnode.state.name = res.user.name || '';
|
|
25
|
+
vnode.state.email = res.user.email || '';
|
|
26
|
+
}
|
|
27
|
+
vnode.state.loading = false;
|
|
28
|
+
m.redraw();
|
|
29
|
+
})
|
|
30
|
+
.catch(err => {
|
|
31
|
+
vnode.state.error = err.message || String(err);
|
|
32
|
+
vnode.state.loading = false;
|
|
33
|
+
m.redraw();
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
view(vnode) {
|
|
38
|
+
const s = vnode.state;
|
|
39
|
+
|
|
40
|
+
const handleSubmit = async (e) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
s.saving = true;
|
|
43
|
+
s.error = null;
|
|
44
|
+
s.success = null;
|
|
45
|
+
|
|
46
|
+
if (s.newPassword) {
|
|
47
|
+
if (!s.currentPassword) {
|
|
48
|
+
s.error = 'Current password is required to change your password';
|
|
49
|
+
s.saving = false;
|
|
50
|
+
m.redraw();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (s.newPassword.length < 6) {
|
|
54
|
+
s.error = 'New password must be at least 6 characters';
|
|
55
|
+
s.saving = false;
|
|
56
|
+
m.redraw();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (s.newPassword !== s.confirmPassword) {
|
|
60
|
+
s.error = 'New passwords do not match';
|
|
61
|
+
s.saving = false;
|
|
62
|
+
m.redraw();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const payload = {
|
|
69
|
+
name: s.name,
|
|
70
|
+
email: s.email,
|
|
71
|
+
};
|
|
72
|
+
if (s.newPassword) {
|
|
73
|
+
payload.currentPassword = s.currentPassword;
|
|
74
|
+
payload.newPassword = s.newPassword;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const res = await api.put('/auth/profile', payload);
|
|
78
|
+
s.saving = false;
|
|
79
|
+
if (res && res.success && res.user) {
|
|
80
|
+
s.success = 'Profile updated successfully';
|
|
81
|
+
s.currentPassword = '';
|
|
82
|
+
s.newPassword = '';
|
|
83
|
+
s.confirmPassword = '';
|
|
84
|
+
if (typeof state !== 'undefined' && state) {
|
|
85
|
+
state.user = res.user;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
m.redraw();
|
|
89
|
+
} catch (err) {
|
|
90
|
+
s.saving = false;
|
|
91
|
+
s.error = err.message || 'Failed to update profile';
|
|
92
|
+
m.redraw();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return m(Layout, { breadcrumbs: [{ label: 'Profile', href: '/profile' }] }, [
|
|
97
|
+
m('div.max-w-4xl.mx-auto.space-y-6', [
|
|
98
|
+
m('div.flex.items-center.justify-between', [
|
|
99
|
+
m('div', [
|
|
100
|
+
m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', 'My Profile'),
|
|
101
|
+
m('p.text-sm.text-gray-500.dark:text-slate-400', 'Manage your personal details and account password'),
|
|
102
|
+
]),
|
|
103
|
+
]),
|
|
104
|
+
|
|
105
|
+
s.loading
|
|
106
|
+
? m('div.flex.justify-center.py-12', m(Spinner))
|
|
107
|
+
: m('form', { onsubmit: handleSubmit }, [
|
|
108
|
+
s.error && m('div.p-4.mb-4.rounded-lg.bg-red-50.dark:bg-red-900/30.text-red-700.dark:text-red-300.text-sm', s.error),
|
|
109
|
+
s.success && m('div.p-4.mb-4.rounded-lg.bg-green-50.dark:bg-green-900/30.text-green-700.dark:text-green-300.text-sm', s.success),
|
|
110
|
+
|
|
111
|
+
// Personal Information Card
|
|
112
|
+
m('div.bg-white.dark:bg-slate-800.rounded-xl.border.border-gray-200.dark:border-slate-700.p-6.mb-6.shadow-sm.space-y-4', [
|
|
113
|
+
m('h2.text-lg.font-semibold.text-gray-900.dark:text-slate-100.border-b.border-gray-100.dark:border-slate-700.pb-3', 'Personal Information'),
|
|
114
|
+
m('div.grid.grid-cols-1.md:grid-cols-2.gap-4', [
|
|
115
|
+
m('div', [
|
|
116
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Name'),
|
|
117
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
118
|
+
type: 'text',
|
|
119
|
+
required: true,
|
|
120
|
+
value: s.name,
|
|
121
|
+
oninput: (e) => { s.name = e.target.value; },
|
|
122
|
+
}),
|
|
123
|
+
]),
|
|
124
|
+
m('div', [
|
|
125
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Email Address'),
|
|
126
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
127
|
+
type: 'email',
|
|
128
|
+
required: true,
|
|
129
|
+
value: s.email,
|
|
130
|
+
oninput: (e) => { s.email = e.target.value; },
|
|
131
|
+
}),
|
|
132
|
+
]),
|
|
133
|
+
]),
|
|
134
|
+
]),
|
|
135
|
+
|
|
136
|
+
// Password Change Card
|
|
137
|
+
m('div.bg-white.dark:bg-slate-800.rounded-xl.border.border-gray-200.dark:border-slate-700.p-6.mb-6.shadow-sm.space-y-4', [
|
|
138
|
+
m('h2.text-lg.font-semibold.text-gray-900.dark:text-slate-100.border-b.border-gray-100.dark:border-slate-700.pb-3', 'Change Password'),
|
|
139
|
+
m('p.text-xs.text-gray-500.dark:text-slate-400', 'Leave blank if you do not wish to change your password'),
|
|
140
|
+
m('div.space-y-4', [
|
|
141
|
+
m('div', [
|
|
142
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Current Password'),
|
|
143
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
144
|
+
type: 'password',
|
|
145
|
+
value: s.currentPassword,
|
|
146
|
+
oninput: (e) => { s.currentPassword = e.target.value; },
|
|
147
|
+
placeholder: '••••••••',
|
|
148
|
+
}),
|
|
149
|
+
]),
|
|
150
|
+
m('div.grid.grid-cols-1.md:grid-cols-2.gap-4', [
|
|
151
|
+
m('div', [
|
|
152
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'New Password'),
|
|
153
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
154
|
+
type: 'password',
|
|
155
|
+
value: s.newPassword,
|
|
156
|
+
oninput: (e) => { s.newPassword = e.target.value; },
|
|
157
|
+
placeholder: 'Min 6 characters',
|
|
158
|
+
}),
|
|
159
|
+
]),
|
|
160
|
+
m('div', [
|
|
161
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Confirm New Password'),
|
|
162
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
163
|
+
type: 'password',
|
|
164
|
+
value: s.confirmPassword,
|
|
165
|
+
oninput: (e) => { s.confirmPassword = e.target.value; },
|
|
166
|
+
placeholder: 'Repeat new password',
|
|
167
|
+
}),
|
|
168
|
+
]),
|
|
169
|
+
]),
|
|
170
|
+
]),
|
|
171
|
+
]),
|
|
172
|
+
|
|
173
|
+
m('div.flex.justify-end', [
|
|
174
|
+
m('button.px-6.py-2.bg-blue-600.hover:bg-blue-700.text-white.font-medium.rounded-lg.shadow-sm.transition-colors.disabled:opacity-50.flex.items-center.gap-2', {
|
|
175
|
+
type: 'submit',
|
|
176
|
+
disabled: s.saving,
|
|
177
|
+
}, [
|
|
178
|
+
s.saving && m(Spinner),
|
|
179
|
+
s.saving ? 'Saving...' : 'Save Profile',
|
|
180
|
+
]),
|
|
181
|
+
]),
|
|
182
|
+
]),
|
|
183
|
+
]),
|
|
184
|
+
]);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
generateProfileComponent,
|
|
192
|
+
};
|
|
@@ -243,6 +243,38 @@
|
|
|
243
243
|
});
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
function sanitizeClientHtml(htmlStr) {
|
|
247
|
+
if (!htmlStr) return '';
|
|
248
|
+
try {
|
|
249
|
+
var parser = new DOMParser();
|
|
250
|
+
var doc = parser.parseFromString(htmlStr, 'text/html');
|
|
251
|
+
var forbiddenTags = ['script', 'iframe', 'object', 'embed', 'applet', 'base'];
|
|
252
|
+
forbiddenTags.forEach(function (tag) {
|
|
253
|
+
var nodes = doc.querySelectorAll(tag);
|
|
254
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
255
|
+
nodes[i].parentNode.removeChild(nodes[i]);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
var allNodes = doc.querySelectorAll('*');
|
|
259
|
+
for (var i = 0; i < allNodes.length; i++) {
|
|
260
|
+
var node = allNodes[i];
|
|
261
|
+
var attrs = Array.prototype.slice.call(node.attributes);
|
|
262
|
+
for (var j = 0; j < attrs.length; j++) {
|
|
263
|
+
var attrName = attrs[j].name.toLowerCase();
|
|
264
|
+
var attrVal = attrs[j].value.toLowerCase().trim();
|
|
265
|
+
if (attrName.indexOf('on') === 0) {
|
|
266
|
+
node.removeAttribute(attrs[j].name);
|
|
267
|
+
} else if ((attrName === 'href' || attrName === 'src') && attrVal.indexOf('javascript:') === 0) {
|
|
268
|
+
node.removeAttribute(attrs[j].name);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return doc.body.innerHTML;
|
|
273
|
+
} catch (e) {
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
246
278
|
function updateDomFields(entryId, data) {
|
|
247
279
|
qsa('[data-ws-content-entry="' + entryId + '"][data-ws-content-field]').forEach(function (el) {
|
|
248
280
|
var field = el.getAttribute('data-ws-content-field');
|
|
@@ -250,7 +282,7 @@
|
|
|
250
282
|
var val = data[field];
|
|
251
283
|
var isHtml = el.getAttribute('data-ws-content-html') === 'true';
|
|
252
284
|
if (isHtml) {
|
|
253
|
-
el.innerHTML = val
|
|
285
|
+
el.innerHTML = sanitizeClientHtml(val);
|
|
254
286
|
} else {
|
|
255
287
|
el.textContent = val != null ? val : '';
|
|
256
288
|
}
|
package/plugins/cors.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso CORS Plugin
|
|
3
|
+
* Zero-dependency CORS middleware & preflight handler
|
|
4
|
+
* @module plugins/cors
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configure CORS middleware
|
|
9
|
+
* @param {Object} [options] - CORS configuration
|
|
10
|
+
* @param {string|string[]|RegExp|Function} [options.origin='*'] - Allowed origin(s)
|
|
11
|
+
* @param {string|string[]} [options.methods] - Allowed HTTP methods
|
|
12
|
+
* @param {string|string[]} [options.allowedHeaders] - Allowed request headers
|
|
13
|
+
* @param {string|string[]} [options.exposedHeaders] - Headers exposed to response
|
|
14
|
+
* @param {boolean} [options.credentials=false] - Allow credentials (cookies/authorization headers)
|
|
15
|
+
* @param {number} [options.maxAge=86400] - Access-Control-Max-Age header in seconds
|
|
16
|
+
* @param {boolean} [options.preflightContinue=false] - Pass OPTIONS to next handler
|
|
17
|
+
* @param {number} [options.optionsSuccessStatus=204] - OPTIONS preflight success status
|
|
18
|
+
* @param {string|string[]|boolean} [options.routes=true] - Route prefixes or true for all
|
|
19
|
+
* @returns {Function} Express middleware handler
|
|
20
|
+
*/
|
|
21
|
+
function createCorsMiddleware(options = {}) {
|
|
22
|
+
const defaults = {
|
|
23
|
+
origin: '*',
|
|
24
|
+
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
|
25
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'],
|
|
26
|
+
exposedHeaders: [],
|
|
27
|
+
credentials: false,
|
|
28
|
+
maxAge: 86400,
|
|
29
|
+
preflightContinue: false,
|
|
30
|
+
optionsSuccessStatus: 204,
|
|
31
|
+
routes: true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const config = { ...defaults, ...options };
|
|
35
|
+
|
|
36
|
+
const methodsStr = Array.isArray(config.methods)
|
|
37
|
+
? config.methods.join(',')
|
|
38
|
+
: String(config.methods);
|
|
39
|
+
|
|
40
|
+
const allowedHeadersStr = Array.isArray(config.allowedHeaders)
|
|
41
|
+
? config.allowedHeaders.join(',')
|
|
42
|
+
: String(config.allowedHeaders);
|
|
43
|
+
|
|
44
|
+
const exposedHeadersStr = Array.isArray(config.exposedHeaders)
|
|
45
|
+
? config.exposedHeaders.join(',')
|
|
46
|
+
: String(config.exposedHeaders);
|
|
47
|
+
|
|
48
|
+
function isOriginAllowed(reqOrigin, callback) {
|
|
49
|
+
if (!config.origin || config.origin === '*') {
|
|
50
|
+
return callback(null, config.origin);
|
|
51
|
+
}
|
|
52
|
+
if (!reqOrigin) {
|
|
53
|
+
return callback(null, false);
|
|
54
|
+
}
|
|
55
|
+
if (typeof config.origin === 'string') {
|
|
56
|
+
return callback(null, config.origin === reqOrigin ? reqOrigin : false);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(config.origin)) {
|
|
59
|
+
for (const item of config.origin) {
|
|
60
|
+
if (typeof item === 'string' && item === reqOrigin) {
|
|
61
|
+
return callback(null, reqOrigin);
|
|
62
|
+
}
|
|
63
|
+
if (item instanceof RegExp && item.test(reqOrigin)) {
|
|
64
|
+
return callback(null, reqOrigin);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return callback(null, false);
|
|
68
|
+
}
|
|
69
|
+
if (config.origin instanceof RegExp) {
|
|
70
|
+
return callback(null, config.origin.test(reqOrigin) ? reqOrigin : false);
|
|
71
|
+
}
|
|
72
|
+
if (typeof config.origin === 'function') {
|
|
73
|
+
return config.origin(reqOrigin, callback);
|
|
74
|
+
}
|
|
75
|
+
return callback(null, false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function shouldApplyToRoute(reqPath) {
|
|
79
|
+
if (config.routes === true || !config.routes) return true;
|
|
80
|
+
const routesList = Array.isArray(config.routes) ? config.routes : [config.routes];
|
|
81
|
+
return routesList.some((prefix) => reqPath.startsWith(prefix));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (req, res, next) => {
|
|
85
|
+
const reqPath = req.path || req.url || '/';
|
|
86
|
+
|
|
87
|
+
if (!shouldApplyToRoute(reqPath)) {
|
|
88
|
+
return next();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const reqOrigin = req.headers.origin;
|
|
92
|
+
|
|
93
|
+
isOriginAllowed(reqOrigin, (err, allowedOrigin) => {
|
|
94
|
+
if (err) {
|
|
95
|
+
return next(err);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (allowedOrigin) {
|
|
99
|
+
if (allowedOrigin === '*' && config.credentials) {
|
|
100
|
+
res.setHeader('Access-Control-Allow-Origin', reqOrigin || '*');
|
|
101
|
+
} else {
|
|
102
|
+
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
|
103
|
+
}
|
|
104
|
+
res.setHeader('Vary', 'Origin');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (config.credentials) {
|
|
108
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (exposedHeadersStr) {
|
|
112
|
+
res.setHeader('Access-Control-Expose-Headers', exposedHeadersStr);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Preflight OPTIONS request
|
|
116
|
+
if (req.method === 'OPTIONS') {
|
|
117
|
+
if (allowedOrigin) {
|
|
118
|
+
if (methodsStr) {
|
|
119
|
+
res.setHeader('Access-Control-Allow-Methods', methodsStr);
|
|
120
|
+
}
|
|
121
|
+
if (allowedHeadersStr) {
|
|
122
|
+
res.setHeader('Access-Control-Allow-Headers', allowedHeadersStr);
|
|
123
|
+
} else if (req.headers['access-control-request-headers']) {
|
|
124
|
+
res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
|
|
125
|
+
}
|
|
126
|
+
if (config.maxAge) {
|
|
127
|
+
res.setHeader('Access-Control-Max-Age', String(config.maxAge));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (config.preflightContinue) {
|
|
132
|
+
return next();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
res.statusCode = config.optionsSuccessStatus;
|
|
136
|
+
res.setHeader('Content-Length', '0');
|
|
137
|
+
return res.end();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
next();
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Webspresso CORS Plugin Factory
|
|
147
|
+
* @param {Object} [options] - CORS options
|
|
148
|
+
* @returns {Object} Plugin definition
|
|
149
|
+
*/
|
|
150
|
+
function corsPlugin(options = {}) {
|
|
151
|
+
return {
|
|
152
|
+
name: 'cors',
|
|
153
|
+
version: '1.0.0',
|
|
154
|
+
options,
|
|
155
|
+
register(ctx) {
|
|
156
|
+
const corsMiddleware = createCorsMiddleware(options);
|
|
157
|
+
|
|
158
|
+
if (ctx.middlewares) {
|
|
159
|
+
ctx.middlewares.cors = corsMiddleware;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (ctx.app && options.global !== false) {
|
|
163
|
+
ctx.app.use(corsMiddleware);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
corsPlugin.createCorsMiddleware = createCorsMiddleware;
|
|
170
|
+
|
|
171
|
+
module.exports = corsPlugin;
|
package/plugins/csrf/index.js
CHANGED
|
@@ -150,7 +150,14 @@ function csrfPlugin(options = {}) {
|
|
|
150
150
|
secret = cookieOptions.signed ? req.signedCookies[cookieKey] : req.cookies[cookieKey];
|
|
151
151
|
if (!secret) {
|
|
152
152
|
secret = crypto.randomBytes(32).toString('hex');
|
|
153
|
-
|
|
153
|
+
const isHttps = req.secure || req.headers?.['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true';
|
|
154
|
+
const effectiveCookieOptions = {
|
|
155
|
+
httpOnly: true,
|
|
156
|
+
sameSite: 'lax',
|
|
157
|
+
...cookieOptions,
|
|
158
|
+
secure: cookieOpts.secure !== undefined ? cookieOpts.secure : isHttps,
|
|
159
|
+
};
|
|
160
|
+
res.cookie(cookieKey, secret, effectiveCookieOptions);
|
|
154
161
|
}
|
|
155
162
|
} else {
|
|
156
163
|
// Session based
|
package/plugins/index.js
CHANGED
|
@@ -24,6 +24,7 @@ const { rateLimitPlugin } = require('./rate-limit');
|
|
|
24
24
|
const emailPlugin = require('./email');
|
|
25
25
|
const contentPlugin = require('./content');
|
|
26
26
|
const csrfPlugin = require('./csrf');
|
|
27
|
+
const corsPlugin = require('./cors');
|
|
27
28
|
|
|
28
29
|
module.exports = {
|
|
29
30
|
sitemapPlugin,
|
|
@@ -47,5 +48,6 @@ module.exports = {
|
|
|
47
48
|
emailPlugin,
|
|
48
49
|
contentPlugin,
|
|
49
50
|
csrfPlugin,
|
|
51
|
+
corsPlugin,
|
|
50
52
|
};
|
|
51
53
|
|
package/src/server.js
CHANGED
|
@@ -349,8 +349,9 @@ function createApp(options = {}) {
|
|
|
349
349
|
if (finalConfig.contentSecurityPolicy && finalConfig.contentSecurityPolicy.directives) {
|
|
350
350
|
const directives = finalConfig.contentSecurityPolicy.directives;
|
|
351
351
|
for (const [directive, sources] of Object.entries(pluginCspSources)) {
|
|
352
|
-
if (sources.size > 0
|
|
353
|
-
directives[directive]
|
|
352
|
+
if (sources.size > 0) {
|
|
353
|
+
const existing = (directives[directive] || []).filter(s => s !== "'none'");
|
|
354
|
+
directives[directive] = [...existing, ...Array.from(sources)];
|
|
354
355
|
}
|
|
355
356
|
}
|
|
356
357
|
}
|
|
@@ -385,6 +386,7 @@ function createApp(options = {}) {
|
|
|
385
386
|
// Add auth middleware to named middlewares for route config
|
|
386
387
|
middlewares.auth = authMiddleware.auth;
|
|
387
388
|
middlewares.guest = authMiddleware.guest;
|
|
389
|
+
middlewares.jwt = authMiddleware.jwt;
|
|
388
390
|
}
|
|
389
391
|
|
|
390
392
|
// Under Vitest, shared API fixtures use `fixtureRequireAuth`; default no-op unless overridden.
|
|
@@ -72,19 +72,27 @@ project/
|
|
|
72
72
|
- **CSP / Helmet:** production **`script-src 'self'`** works for **`/__webspresso/client-runtime/`**; some Alpine builds may need **`unsafe-eval`** — validate for your version or use a stricter build.
|
|
73
73
|
- **Longer doc:** **[`doc/index.html#client-runtime`](../../../doc/index.html#client-runtime)** · README **Client runtime**.
|
|
74
74
|
|
|
75
|
-
###
|
|
76
|
-
|
|
77
|
-
- **Import:** `const { createAuth, quickAuth, hash, verify, createRememberTokensTable } = require('webspresso/core/auth')` (published `core/` tree on npm; **not** re-exported from package root).
|
|
78
|
-
-
|
|
79
|
-
-
|
|
75
|
+
### Dual authentication (Session + JWT & Refresh Tokens) — essentials
|
|
76
|
+
|
|
77
|
+
- **Import:** `const { createAuth, quickAuth, hash, verify, signJwt, verifyJwt, decodeJwt, createRememberTokensTable } = require('webspresso/core/auth')` (published `core/` tree on npm; **not** re-exported from package root).
|
|
78
|
+
- **Dual Architecture:**
|
|
79
|
+
- **Session + Cookie (Stateful):** Primary for browser SSR routes and Admin Panel (`/`, `/_admin`). Uses `httpOnly` secure cookies.
|
|
80
|
+
- **JWT + Refresh Token (Stateless):** Built-in zero-dependency HS256 auth for REST APIs, Mobile Apps, and SPAs (`/api/*`). Uses `Authorization: Bearer <token>` for access tokens and rotatable Refresh Tokens.
|
|
81
|
+
- **`createAuth({ findUserById, findUserByCredentials, session: { secret }, jwt: { secret, refreshSecret, expiresIn, refreshExpiresIn }, rememberTokens?, ... })`** — adapter pattern; optional **remember-me** via `rememberTokens` and opt-in **JWT / Refresh Tokens**.
|
|
82
|
+
- **`quickAuth({ db, userModel, identifierField, passwordField, rememberMe, jwt })`** — wires **`getRepository`** + bcrypt **`verify`**; optional Knex **`remember_tokens`** and **JWT / Refresh Token** manager (`jwt: true` or `jwt: { expiresIn: '15m', refreshExpiresIn: '30d' }`).
|
|
83
|
+
- **JWT Helper Methods:**
|
|
84
|
+
- **`auth.generateUserToken(user, options)`** — generates short-lived access JWT (`type: 'access'`).
|
|
85
|
+
- **`auth.generateRefreshToken(user, options)`** — generates long-lived refresh JWT (`type: 'refresh'`).
|
|
86
|
+
- **`auth.refreshAccessToken(refreshToken, options)`** — verifies refresh token and issues new access token + rotated refresh token.
|
|
87
|
+
- **`auth.requireJwt()`** — guard middleware enforcing valid `Bearer` access token.
|
|
80
88
|
- **Request API** (after global authenticate): **`req.auth.attempt(id, password, { remember })`**, **`login`**, **`logout`**, **`check`**, **`guest`**, **`user`**, **`id`**, **`can` / `cannot` / `authorize`** (policies: **`auth.definePolicy`**, **`defineGate`**, **`beforePolicy`**).
|
|
81
|
-
- **Route config:** `middleware: ['auth']` (
|
|
89
|
+
- **Route config:** `middleware: ['auth']` (SSR logged in), `middleware: ['jwt']` (API Bearer token), or `['guest']` (logged-out only). For JSON APIs mounted in **`setupRoutes`**, use **`ctx.authMiddleware.requireJwt()`** or **`requireAuth({ api: true })`**.
|
|
82
90
|
- **Login page pitfall:** a **`pages/login.njk`** can register **before** `setupRoutes` and bypass **`requireGuest`**. Prefer login GET/POST in **`setupRoutes`** with templates under **`views/`** only, or omit **`pages/login.njk`** — see [`tests/e2e/auth.spec.js`](../../../tests/e2e/auth.spec.js).
|
|
83
91
|
- **Admin panel** uses a **separate** session (`req.session.adminUser`, `/_admin/api/auth/*`); it does **not** replace **`createApp({ auth })`** for site users.
|
|
84
92
|
- **Site users in the admin UI (`userManagement`):** Opt-in on **`adminPanelPlugin`**. Set **`userManagement: { enabled: true, model: 'User', fields?: { ... } }`** so the SPA shows **Users**: sidebar **All Users** / **Add User** link to **`/_admin/models/{model}`** and **`/_admin/models/{model}/new`** (same RecordList/RecordForm as other models). **`/_admin/users`**, **`/_admin/users/new`**, **`/_admin/users/:id/edit`** remain SPA aliases that redirect there; **`/_admin/users/sessions`** is **Active Sessions**. The **`model`** must match site auth (e.g. **`quickAuth({ userModel: 'User', ... })`** / **`createAuth`**) and must have **`admin: { enabled: true, ... }`** on **`defineModel`** so admin CRUD metadata loads; otherwise Users screens look empty. Pass **`auth: authManager`** with the **same** **`AuthManager`** as **`createApp({ auth: authManager })`** for **Active Sessions** / revoke (**`rememberTokens`** / **`remember_me`**); without **`auth`**, user CRUD still works via **`db.getRepository(model)`**, but session endpoints return empty or “not enabled”.
|
|
85
93
|
- **Wiring:** `plugins: [ adminPanelPlugin({ db, auth: authManager, userManagement: { enabled: true, model: 'User' } }) ]` alongside `createApp({ ..., auth: authManager })`. Admin staff log in at **`/_admin`**; end users use your normal site login — two different cookies/sessions.
|
|
86
94
|
|
|
87
|
-
Longer narrative: **[`doc/index.html#authentication`](../../../doc/index.html#authentication)** · **[`#admin-user-management`](../../../doc/index.html#admin-user-management)** · README **Authentication (session)** and **Admin Panel Plugin**.
|
|
95
|
+
Longer narrative: **[`doc/index.html#authentication`](../../../doc/index.html#authentication)** · **[`#admin-user-management`](../../../doc/index.html#admin-user-management)** · README **Authentication (session & JWT)** and **Admin Panel Plugin**.
|
|
88
96
|
|
|
89
97
|
---
|
|
90
98
|
|
|
@@ -195,7 +203,7 @@ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and pl
|
|
|
195
203
|
| `dashboardPlugin` | Dev route `/_webspresso` — route list |
|
|
196
204
|
| `sitemapPlugin` | `/sitemap.xml`, robots; optional DB-driven URLs |
|
|
197
205
|
| `analyticsPlugin` | GA / GTM / Yandex / Bing / Facebook — `fsy` helpers |
|
|
198
|
-
| `adminPanelPlugin` | SPA admin CRUD — needs **`db`**; optional **`uploadUrl`** (or infer from **`uploadPlugin`**); optional **`userManagement: { enabled, model, fields }`** + **`auth`** (same **`AuthManager`** as **`createApp({ auth })`**) for site-user CRUD + remember-me session UI — see **Session authentication** above |
|
|
206
|
+
| `adminPanelPlugin` | SPA admin CRUD — needs **`db`**; supports custom HTML & external URL pages (`url`, `html`, `htmlFile`, `layout: false`) and SSR script/style imports (`scripts`, `styles`, `registerScript`, `registerStyle`); optional **`uploadUrl`** (or infer from **`uploadPlugin`**); optional **`userManagement: { enabled, model, fields }`** + **`auth`** (same **`AuthManager`** as **`createApp({ auth })`**) for site-user CRUD + remember-me session UI — see **Session authentication** above |
|
|
199
207
|
| `dataExchangePlugin` | Admin-only **Excel export** + **CSV/XLSX import** under `${adminPath}/api/data-exchange/*`; register **after** `adminPanelPlugin` with same `db` / `adminPath`; optional `maxRows`, `maxFileBytes`; adds UI buttons + bulk `export-xlsx` |
|
|
200
208
|
| `redirectPlugin` | Configurable **301–308** redirects in `register()` — runs **before** file-based SSR routes; `rules` (`from` path or `RegExp`, `to`, `status`, `methods`), `preserveQuery`, `allowExternal`, `trailingSlash`, `defaultMethods`; docs **[`doc/index.html#plugins-redirect`](../../../doc/index.html#plugins-redirect)**, README **Redirect plugin** |
|
|
201
209
|
| `uploadPlugin` | `POST` multipart (`multer`), `createLocalFileProvider` or custom `provider`; set **`mimeAllowlist`** / **`maxBytes`** in production; pairs with admin **`zdb.file()`** / **`customFields.file-upload`** when registered before **`adminPanelPlugin`** |
|