kempo 4.2.39 → 4.3.0
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 +7 -0
- package/app-public/default.template.html +1 -1
- package/dist/admin/nav.fragment.html +5 -0
- package/dist/admin/realtime/index.page.html +81 -0
- package/dist/admin/settings/[owner]/[name]/index.page.html +52 -1
- package/dist/admin/settings/index.page.html +12 -1
- package/dist/kempo/api/realtime/WS.js +2 -0
- package/dist/kempo/api/realtime/WS.js.map +1 -0
- package/dist/kempo/api/realtime/connections/GET.js +2 -0
- package/dist/kempo/api/realtime/connections/GET.js.map +1 -0
- package/dist/kempo/icons/credit_card.svg +1 -0
- package/dist/kempo/icons/sensors.svg +1 -0
- package/dist/kempo/realtime.js +2 -0
- package/dist/kempo/realtime.js.map +1 -0
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +1 -1
- package/middleware/kempo.js +30 -15
- package/package.json +2 -2
- package/scripts/init-db.js +4 -1
- package/server/db/schema.js +30 -1
- package/server/sdk.js +5 -0
- package/server/utils/extensions/contentDirs.js +27 -0
- package/server/utils/realtime/Hub.js +676 -0
- package/server/utils/realtime/channels.js +247 -0
- package/server/utils/realtime/closeConnection.js +4 -0
- package/server/utils/realtime/constants.js +20 -0
- package/server/utils/realtime/duration.js +18 -0
- package/server/utils/realtime/getHub.js +15 -0
- package/server/utils/realtime/index.js +14 -0
- package/server/utils/realtime/listConnections.js +4 -0
- package/server/utils/realtime/listSubscribers.js +4 -0
- package/server/utils/realtime/pruneMessages.js +45 -0
- package/server/utils/realtime/publish.js +86 -0
- package/server/utils/realtime/sendToConnection.js +4 -0
- package/server/utils/routing/serveUnmatched.js +8 -1
- package/server/utils/settings/getPublicSettings.js +4 -2
- package/server/utils/settings/getSettingWithMetadata.js +11 -4
- package/server/utils/settings/helpers.js +8 -2
- package/server/utils/settings/listSettings.js +9 -1
- package/server/utils/settings/secretCrypto.js +41 -0
- package/server/utils/settings/setSetting.js +31 -10
package/middleware/kempo.js
CHANGED
|
@@ -6,6 +6,7 @@ import getSession from '../server/utils/auth/getSession.js';
|
|
|
6
6
|
import currentUserHasPermission from '../server/utils/permissions/currentUserHasPermission.js';
|
|
7
7
|
import { LEXICAL_BASE, bundleFileName as lexicalBundleFileName } from '../server/utils/lexical/packages.js';
|
|
8
8
|
import { getEnabledExtensions } from '../server/utils/extensions/scopeCache.js';
|
|
9
|
+
import { extensionAdminDirs, extensionPublicDirs } from '../server/utils/extensions/contentDirs.js';
|
|
9
10
|
import triggerHook from '../server/utils/hooks/triggerHook.js';
|
|
10
11
|
import { ADMIN_GLOBALS_DIR } from '../server/utils/admin-global-content/helpers.js';
|
|
11
12
|
|
|
@@ -112,7 +113,7 @@ const executeRouteFile = async (filePath, request, response, params = {}) => {
|
|
|
112
113
|
extensions guard their own private pages. Rendering a page anywhere without this helper silently
|
|
113
114
|
bypasses those guards.
|
|
114
115
|
*/
|
|
115
|
-
const renderGuardedPage = async (pageFilePath, request, response, rootDir, resolveDir, params = {}, extraGlobalDirs = []) => {
|
|
116
|
+
const renderGuardedPage = async (pageFilePath, request, response, rootDir, resolveDir, params = {}, extraGlobalDirs = [], extraFragmentDirs = []) => {
|
|
116
117
|
try {
|
|
117
118
|
await triggerHook('middleware:before_page', {
|
|
118
119
|
url: request.url.split('?')[0],
|
|
@@ -127,7 +128,7 @@ const renderGuardedPage = async (pageFilePath, request, response, rootDir, resol
|
|
|
127
128
|
return;
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
const html = await renderExternalPage(pageFilePath, rootDir, resolveDir, {}, {}, 10, extraGlobalDirs);
|
|
131
|
+
const html = await renderExternalPage(pageFilePath, rootDir, resolveDir, {}, {}, 10, extraGlobalDirs, extraFragmentDirs);
|
|
131
132
|
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
132
133
|
response.end(html);
|
|
133
134
|
};
|
|
@@ -145,16 +146,22 @@ const renderGuardedPage = async (pageFilePath, request, response, rootDir, resol
|
|
|
145
146
|
*/
|
|
146
147
|
export const adminGlobalDirs = async () => [
|
|
147
148
|
ADMIN_GLOBALS_DIR,
|
|
148
|
-
...
|
|
149
|
+
...await extensionAdminDirs(),
|
|
149
150
|
];
|
|
150
151
|
|
|
151
|
-
|
|
152
|
+
/*
|
|
153
|
+
Fragments an admin render may pull from extension packages. ADMIN_GLOBALS_DIR is deliberately not
|
|
154
|
+
here: it holds admin-authored global content and never fragments.
|
|
155
|
+
*/
|
|
156
|
+
export const adminFragmentDirs = () => extensionAdminDirs();
|
|
157
|
+
|
|
158
|
+
const serveDir = async (dirPath, method, request, response, resolveDir, rootDir, params = {}, extraGlobalDirs = [], extraFragmentDirs = []) => {
|
|
152
159
|
const candidates = [`${method}.js`, 'index.page.html', 'index.js', 'index.html', 'CATCH.js'];
|
|
153
160
|
for(const candidate of candidates){
|
|
154
161
|
const candidatePath = join(dirPath, candidate);
|
|
155
162
|
try { await stat(candidatePath); } catch { continue; }
|
|
156
163
|
if(candidate.endsWith('.page.html')){
|
|
157
|
-
await renderGuardedPage(candidatePath, request, response, rootDir, resolveDir, params, extraGlobalDirs);
|
|
164
|
+
await renderGuardedPage(candidatePath, request, response, rootDir, resolveDir, params, extraGlobalDirs, extraFragmentDirs);
|
|
158
165
|
return true;
|
|
159
166
|
}
|
|
160
167
|
if(ROUTE_FILES.includes(candidate)){
|
|
@@ -271,9 +278,10 @@ export default config => {
|
|
|
271
278
|
const segments = url.slice('/admin'.length).replace(/^\//, '').split('/').filter(Boolean);
|
|
272
279
|
const resolveDir = buildResolveDir(ADMIN_ROOT, url);
|
|
273
280
|
const globalDirs = await adminGlobalDirs();
|
|
281
|
+
const fragmentDirs = await adminFragmentDirs();
|
|
274
282
|
|
|
275
283
|
if(segments.length === 0){
|
|
276
|
-
const served = await serveDir(ADMIN_ROOT, method, request, response, resolveDir, ADMIN_ROOT, {}, globalDirs);
|
|
284
|
+
const served = await serveDir(ADMIN_ROOT, method, request, response, resolveDir, ADMIN_ROOT, {}, globalDirs, fragmentDirs);
|
|
277
285
|
if(served) return;
|
|
278
286
|
} else {
|
|
279
287
|
const walked = await walkDynamic(ADMIN_ROOT, segments);
|
|
@@ -283,12 +291,12 @@ export default config => {
|
|
|
283
291
|
try { fileStat = await stat(filePath); } catch { /* not found */ }
|
|
284
292
|
|
|
285
293
|
if(fileStat?.isDirectory()){
|
|
286
|
-
const served = await serveDir(filePath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs);
|
|
294
|
+
const served = await serveDir(filePath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs, fragmentDirs);
|
|
287
295
|
if(served) return;
|
|
288
296
|
} else if(fileStat?.isFile()){
|
|
289
297
|
const name = filePath.split(/[/\\]/).pop();
|
|
290
298
|
if(name.endsWith('.page.html')){
|
|
291
|
-
await renderGuardedPage(filePath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs);
|
|
299
|
+
await renderGuardedPage(filePath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs, fragmentDirs);
|
|
292
300
|
return;
|
|
293
301
|
}
|
|
294
302
|
if(ROUTE_FILES.includes(name)){
|
|
@@ -317,6 +325,7 @@ export default config => {
|
|
|
317
325
|
const resolveDir = buildResolveDir(ADMIN_ROOT, url);
|
|
318
326
|
const method = request.method?.toUpperCase() || 'GET';
|
|
319
327
|
const globalDirs = await adminGlobalDirs();
|
|
328
|
+
const fragmentDirs = await adminFragmentDirs();
|
|
320
329
|
|
|
321
330
|
const pageCandidates = subPath.endsWith('/')
|
|
322
331
|
? [join(adminDir, subPath, 'index.page.html')]
|
|
@@ -329,7 +338,7 @@ export default config => {
|
|
|
329
338
|
try {
|
|
330
339
|
await stat(pagePath);
|
|
331
340
|
} catch { continue; }
|
|
332
|
-
await renderGuardedPage(pagePath, request, response, ADMIN_ROOT, resolveDir, {}, globalDirs);
|
|
341
|
+
await renderGuardedPage(pagePath, request, response, ADMIN_ROOT, resolveDir, {}, globalDirs, fragmentDirs);
|
|
333
342
|
return;
|
|
334
343
|
}
|
|
335
344
|
|
|
@@ -351,12 +360,12 @@ export default config => {
|
|
|
351
360
|
let walkedStat;
|
|
352
361
|
try { walkedStat = await stat(walkedPath); } catch { /* not found */ }
|
|
353
362
|
if(walkedStat?.isDirectory()){
|
|
354
|
-
const served = await serveDir(walkedPath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs);
|
|
363
|
+
const served = await serveDir(walkedPath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs, fragmentDirs);
|
|
355
364
|
if(served) return;
|
|
356
365
|
} else if(walkedStat?.isFile()){
|
|
357
366
|
const name = walkedPath.split(/[/\\]/).pop();
|
|
358
367
|
if(name.endsWith('.page.html')){
|
|
359
|
-
await renderGuardedPage(walkedPath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs);
|
|
368
|
+
await renderGuardedPage(walkedPath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs, fragmentDirs);
|
|
360
369
|
return;
|
|
361
370
|
}
|
|
362
371
|
if(ROUTE_FILES.includes(name)){
|
|
@@ -378,6 +387,12 @@ export default config => {
|
|
|
378
387
|
*/
|
|
379
388
|
const extensions = await getEnabledExtensions();
|
|
380
389
|
|
|
390
|
+
/*
|
|
391
|
+
Resolved once for every public render below — an extension's own scoped pages, the consumer's
|
|
392
|
+
pages, and the 404 alike — so any enabled extension can contribute to any of them.
|
|
393
|
+
*/
|
|
394
|
+
const publicContentDirs = await extensionPublicDirs();
|
|
395
|
+
|
|
381
396
|
for(const ext of extensions){
|
|
382
397
|
let pkgPath, pkg;
|
|
383
398
|
try {
|
|
@@ -398,7 +413,7 @@ export default config => {
|
|
|
398
413
|
const segments = subPath.replace(/^\//, '').split('/').filter(Boolean);
|
|
399
414
|
|
|
400
415
|
if(segments.length === 0){
|
|
401
|
-
const served = await serveDir(publicDir, method, request, response, resolveDir, PROJECT_PUBLIC);
|
|
416
|
+
const served = await serveDir(publicDir, method, request, response, resolveDir, PROJECT_PUBLIC, {}, publicContentDirs, publicContentDirs);
|
|
402
417
|
if(served) return;
|
|
403
418
|
return next();
|
|
404
419
|
}
|
|
@@ -410,12 +425,12 @@ export default config => {
|
|
|
410
425
|
try { fileStat = await stat(filePath); } catch { /* not found */ }
|
|
411
426
|
|
|
412
427
|
if(fileStat?.isDirectory()){
|
|
413
|
-
const served = await serveDir(filePath, method, request, response, resolveDir, PROJECT_PUBLIC, params);
|
|
428
|
+
const served = await serveDir(filePath, method, request, response, resolveDir, PROJECT_PUBLIC, params, publicContentDirs, publicContentDirs);
|
|
414
429
|
if(served) return;
|
|
415
430
|
} else if(fileStat?.isFile()){
|
|
416
431
|
const name = filePath.split(/[/\\]/).pop();
|
|
417
432
|
if(name.endsWith('.page.html')){
|
|
418
|
-
await renderGuardedPage(filePath, request, response, PROJECT_PUBLIC, resolveDir, params);
|
|
433
|
+
await renderGuardedPage(filePath, request, response, PROJECT_PUBLIC, resolveDir, params, publicContentDirs, publicContentDirs);
|
|
419
434
|
return;
|
|
420
435
|
}
|
|
421
436
|
if(ROUTE_FILES.includes(name)){
|
|
@@ -448,7 +463,7 @@ export default config => {
|
|
|
448
463
|
|
|
449
464
|
for(const pageFilePath of candidates){
|
|
450
465
|
try { await stat(pageFilePath); } catch { continue; }
|
|
451
|
-
await renderGuardedPage(pageFilePath, request, response, PROJECT_PUBLIC, resolveDir);
|
|
466
|
+
await renderGuardedPage(pageFilePath, request, response, PROJECT_PUBLIC, resolveDir, {}, publicContentDirs, publicContentDirs);
|
|
452
467
|
return;
|
|
453
468
|
}
|
|
454
469
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kempo",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fullstack CMS framework for creating websites with user authentication",
|
|
6
6
|
"scripts": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"kempo-css": ">=2.2.5",
|
|
61
|
-
"kempo-server": ">=3.
|
|
61
|
+
"kempo-server": ">=3.4.0",
|
|
62
62
|
"kempo-ui": ">=0.4.39"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
package/scripts/init-db.js
CHANGED
|
@@ -46,6 +46,8 @@ const DEFAULT_PERMISSIONS = [
|
|
|
46
46
|
{ name: 'system:extensions:uninstall', description: 'Uninstall extensions' },
|
|
47
47
|
{ name: 'system:extensions:manage', description: 'Enable and disable extensions' },
|
|
48
48
|
|
|
49
|
+
{ name: 'system:realtime:read', description: 'View realtime connections and channels' },
|
|
50
|
+
|
|
49
51
|
{ name: 'system:admin:access', description: 'Access admin panel' },
|
|
50
52
|
];
|
|
51
53
|
|
|
@@ -66,7 +68,8 @@ const DEFAULT_GROUPS = {
|
|
|
66
68
|
'system:menus:create', 'system:menus:read', 'system:menus:update', 'system:menus:delete',
|
|
67
69
|
'system:fragments:read', 'system:fragments:create', 'system:fragments:update', 'system:fragments:delete',
|
|
68
70
|
'system:globals:read', 'system:globals:create', 'system:globals:update', 'system:globals:delete',
|
|
69
|
-
'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage'
|
|
71
|
+
'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage',
|
|
72
|
+
'system:realtime:read'
|
|
70
73
|
]
|
|
71
74
|
}
|
|
72
75
|
};
|
package/server/db/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pgTable, text, timestamp, boolean, index, jsonb } from 'drizzle-orm/pg-core';
|
|
1
|
+
import { pgTable, text, timestamp, boolean, index, jsonb, bigserial, bigint } from 'drizzle-orm/pg-core';
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
4
|
Auth Tables
|
|
@@ -114,6 +114,35 @@ export const extension = pgTable('extension', {
|
|
|
114
114
|
updatedAt: timestamp('updatedAt').notNull(),
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
/*
|
|
118
|
+
Realtime System
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/*
|
|
122
|
+
A published message, kept only for channels that opt in to persistence. `id` is what a client
|
|
123
|
+
hands back as `since` to replay what it missed, so it must be ordered within a channel: publish
|
|
124
|
+
takes a per-channel lock, otherwise two rows could commit out of id order and a replay from the
|
|
125
|
+
earlier id would skip the later-committing one for good.
|
|
126
|
+
*/
|
|
127
|
+
export const realtimeMessage = pgTable('realtimeMessage', {
|
|
128
|
+
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
|
129
|
+
channel: text('channel').notNull(),
|
|
130
|
+
data: jsonb('data').notNull(),
|
|
131
|
+
createdAt: timestamp('createdAt').notNull(),
|
|
132
|
+
}, table => [
|
|
133
|
+
index('realtimeMessage_channel_id_idx').on(table.channel, table.id),
|
|
134
|
+
index('realtimeMessage_createdAt_idx').on(table.createdAt),
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
How far a channel has been pruned. Ids are shared across channels, so a gap cannot be inferred from
|
|
139
|
+
id arithmetic; a client whose `since` is below this watermark has provably missed messages.
|
|
140
|
+
*/
|
|
141
|
+
export const realtimeChannel = pgTable('realtimeChannel', {
|
|
142
|
+
channel: text('channel').primaryKey(),
|
|
143
|
+
prunedThrough: bigint('prunedThrough', { mode: 'number' }).notNull().default(0),
|
|
144
|
+
});
|
|
145
|
+
|
|
117
146
|
/*
|
|
118
147
|
Triggers to prevent deletion of system resources
|
|
119
148
|
*/
|
package/server/sdk.js
CHANGED
|
@@ -196,3 +196,8 @@ export { default as listExtensions } from './utils/extensions/listExtensions.js'
|
|
|
196
196
|
export { default as uninstallExtension } from './utils/extensions/uninstallExtension.js';
|
|
197
197
|
export { default as updateExtension } from './utils/extensions/updateExtension.js';
|
|
198
198
|
|
|
199
|
+
/*
|
|
200
|
+
Realtime
|
|
201
|
+
*/
|
|
202
|
+
|
|
203
|
+
export * as realtime from './utils/realtime/index.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { getEnabledExtensions } from './scopeCache.js';
|
|
3
|
+
|
|
4
|
+
const NODE_MODULES = join(process.cwd(), 'node_modules');
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
The directories an enabled extension may contribute *.global.html and *.fragment.html from, split
|
|
8
|
+
by which half of the site is rendering: `admin/` for the admin portal, `public/` for the live site.
|
|
9
|
+
|
|
10
|
+
Each is read straight from the package at render time, the same way an extension's pages and
|
|
11
|
+
routes are resolved, so nothing is copied anywhere on install: enabling, disabling, upgrading or
|
|
12
|
+
removing an extension takes effect immediately, and there is nothing left behind to clean up.
|
|
13
|
+
|
|
14
|
+
That is also the whole of the on/off switch for this content — a disabled extension drops out of
|
|
15
|
+
these lists and its files are simply never read. There is no `enabled` flag on a packaged file the
|
|
16
|
+
way there is on admin-authored global content, because there is no stored row to carry one.
|
|
17
|
+
|
|
18
|
+
Lives here rather than in middleware/kempo.js so that server utils can reach it too — the 404 page
|
|
19
|
+
is rendered from server/utils/routing/serveUnmatched.js, and importing the middleware from there
|
|
20
|
+
would be circular.
|
|
21
|
+
*/
|
|
22
|
+
const extensionDirs = async sub =>
|
|
23
|
+
(await getEnabledExtensions()).map(ext => join(NODE_MODULES, ext.name, sub));
|
|
24
|
+
|
|
25
|
+
export const extensionAdminDirs = () => extensionDirs('admin');
|
|
26
|
+
|
|
27
|
+
export const extensionPublicDirs = () => extensionDirs('public');
|