kempo 4.6.2 → 4.7.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 +5 -0
- package/app-public/account/index.page.html +2 -0
- package/app-public/nav.fragment.html +2 -1
- package/app-templates/emails/email.template.html +14 -0
- package/app-templates/emails/reset-password.page.html +19 -0
- package/app-templates/emails/test.page.html +21 -0
- package/app-templates/emails/verify-email.page.html +18 -0
- package/bin/cli.js +3 -0
- package/dist/admin/components/Dashboard.js +2 -2
- package/dist/admin/components/Dashboard.js.map +1 -1
- package/dist/admin/components/Menus.js +103 -0
- package/dist/admin/components/Menus.js.map +1 -0
- package/dist/admin/content/menus/index.page.html +11 -0
- package/dist/admin/nav.fragment.html +6 -0
- package/dist/kempo/api/fragments/DELETE.js +1 -1
- package/dist/kempo/api/fragments/DELETE.js.map +1 -1
- package/dist/kempo/api/fragments/GET.js +1 -1
- package/dist/kempo/api/fragments/GET.js.map +1 -1
- package/dist/kempo/api/fragments/POST.js +1 -1
- package/dist/kempo/api/fragments/POST.js.map +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/file/GET.js +1 -1
- package/dist/kempo/api/fragments/file/GET.js.map +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
- package/dist/kempo/api/fragments/file/PUT.js +1 -1
- package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
- package/dist/kempo/api/globals/DELETE.js +1 -1
- package/dist/kempo/api/globals/DELETE.js.map +1 -1
- package/dist/kempo/api/globals/GET.js +1 -1
- package/dist/kempo/api/globals/GET.js.map +1 -1
- package/dist/kempo/api/globals/POST.js +1 -1
- package/dist/kempo/api/globals/POST.js.map +1 -1
- package/dist/kempo/api/globals/disable/PUT.js +1 -1
- package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/enable/PUT.js +1 -1
- package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/entry/GET.js +1 -1
- package/dist/kempo/api/globals/entry/GET.js.map +1 -1
- package/dist/kempo/api/globals/entry/PUT.js +1 -1
- package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
- package/dist/kempo/api/globals/lock/PUT.js +1 -1
- package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
- package/dist/kempo/api/menus/DELETE.js +2 -0
- package/dist/kempo/api/menus/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/GET.js +2 -0
- package/dist/kempo/api/menus/GET.js.map +1 -0
- package/dist/kempo/api/menus/POST.js +2 -0
- package/dist/kempo/api/menus/POST.js.map +1 -0
- package/dist/kempo/api/menus/adopt/PUT.js +2 -0
- package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/DELETE.js +2 -0
- package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/items/POST.js +2 -0
- package/dist/kempo/api/menus/items/POST.js.map +1 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/order/PUT.js +2 -0
- package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
- package/dist/kempo/api/menus/location/PUT.js +2 -0
- package/dist/kempo/api/menus/location/PUT.js.map +1 -0
- package/dist/kempo/api/pages/DELETE.js +1 -1
- package/dist/kempo/api/pages/DELETE.js.map +1 -1
- package/dist/kempo/api/pages/GET.js +1 -1
- package/dist/kempo/api/pages/GET.js.map +1 -1
- package/dist/kempo/api/pages/POST.js +1 -1
- package/dist/kempo/api/pages/POST.js.map +1 -1
- package/dist/kempo/api/pages/directories/GET.js +1 -1
- package/dist/kempo/api/pages/directories/GET.js.map +1 -1
- package/dist/kempo/api/pages/disable/PUT.js +1 -1
- package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/enable/PUT.js +1 -1
- package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/file/GET.js +1 -1
- package/dist/kempo/api/pages/file/GET.js.map +1 -1
- package/dist/kempo/api/pages/file/PATCH.js +1 -1
- package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
- package/dist/kempo/api/pages/file/PUT.js +1 -1
- package/dist/kempo/api/pages/file/PUT.js.map +1 -1
- package/dist/kempo/api/pages/lock/PUT.js +1 -1
- package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
- package/dist/kempo/api/pages/rescan/POST.js +1 -1
- package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
- package/dist/kempo/api/pages/search/GET.js +1 -1
- package/dist/kempo/api/pages/search/GET.js.map +1 -1
- package/dist/kempo/api/pages/templates/GET.js +1 -1
- package/dist/kempo/api/pages/templates/GET.js.map +1 -1
- package/dist/kempo/api/templates/DELETE.js +1 -1
- package/dist/kempo/api/templates/DELETE.js.map +1 -1
- package/dist/kempo/api/templates/GET.js +1 -1
- package/dist/kempo/api/templates/GET.js.map +1 -1
- package/dist/kempo/api/templates/POST.js +1 -1
- package/dist/kempo/api/templates/POST.js.map +1 -1
- package/dist/kempo/api/templates/disable/PUT.js +1 -1
- package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/enable/PUT.js +1 -1
- package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/file/GET.js +1 -1
- package/dist/kempo/api/templates/file/GET.js.map +1 -1
- package/dist/kempo/api/templates/file/PATCH.js +1 -1
- package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
- package/dist/kempo/api/templates/file/PUT.js +1 -1
- package/dist/kempo/api/templates/file/PUT.js.map +1 -1
- package/dist/kempo/api/templates/lock/PUT.js +1 -1
- package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
- package/dist/kempo/components/DashboardWidget.js +4 -4
- package/dist/kempo/components/DashboardWidget.js.map +1 -1
- package/dist/kempo/icons/campaign.svg +1 -0
- package/dist/kempo/icons/fields.svg +1 -0
- package/dist/kempo/icons/open_in_new.svg +1 -0
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +1 -1
- package/middleware/kempo.js +48 -12
- package/package.json +2 -2
- package/server/db/schema.js +24 -0
- package/server/sdk.js +21 -0
- package/server/utils/auth/requestPasswordReset.js +9 -3
- package/server/utils/auth/verifyEmail.js +10 -1
- package/server/utils/dashboard/coreWidgets.js +1 -0
- package/server/utils/dashboard/helpers.js +6 -0
- package/server/utils/email/renderEmailTemplate.js +63 -0
- package/server/utils/email/sendEmailFromTemplate.js +9 -27
- package/server/utils/extensions/aliases.js +71 -0
- package/server/utils/extensions/checkClaims.js +32 -0
- package/server/utils/extensions/claims.js +54 -0
- package/server/utils/extensions/createTablesFromSchema.js +13 -20
- package/server/utils/extensions/enableExtensionTree.js +8 -0
- package/server/utils/extensions/installExtension.js +32 -0
- package/server/utils/extensions/readKempoConfig.js +2 -2
- package/server/utils/extensions/scopeCache.js +14 -1
- package/server/utils/extensions/scopes.js +72 -0
- package/server/utils/extensions/uninstallExtensionTree.js +2 -1
- package/server/utils/extensions/updateExtension.js +35 -0
- package/server/utils/global-content/createGlobalContent.js +8 -18
- package/server/utils/global-content/deleteGlobalContent.js +1 -16
- package/server/utils/global-content/disableGlobalContent.js +1 -16
- package/server/utils/global-content/enableGlobalContent.js +1 -16
- package/server/utils/global-content/helpers.js +40 -6
- package/server/utils/global-content/listGlobalContent.js +2 -2
- package/server/utils/global-content/setGlobalContentLocked.js +1 -16
- package/server/utils/global-content/updateGlobalContent.js +1 -16
- package/server/utils/menus/adoptMenuItem.js +58 -0
- package/server/utils/menus/assignMenuLocation.js +47 -0
- package/server/utils/menus/deleteMenu.js +27 -0
- package/server/utils/menus/deleteMenuItems.js +27 -0
- package/server/utils/menus/findMenuLocations.js +41 -0
- package/server/utils/menus/findNavItems.js +74 -0
- package/server/utils/menus/findPages.js +73 -0
- package/server/utils/menus/listMenus.js +103 -0
- package/server/utils/menus/markup.js +55 -0
- package/server/utils/menus/reorderMenuItems.js +36 -0
- package/server/utils/menus/saveMenu.js +54 -0
- package/server/utils/menus/saveMenuItem.js +122 -0
- package/server/utils/menus/setMenuItemEnabled.js +31 -0
- package/server/utils/menus/store.js +90 -0
- package/server/utils/scheduler/helpers.js +36 -0
- package/server/utils/scheduler/listJobs.js +11 -0
- package/server/utils/scheduler/registerJobs.js +37 -0
- package/server/utils/scheduler/runJobs.js +90 -0
- package/server/utils/scheduler/scheduler.js +37 -0
- package/server/utils/scheduler/unregisterJobs.js +16 -0
- package/server/utils/settings/ensureEncryptionKey.js +132 -0
- package/server/utils/settings/secretCrypto.js +5 -1
- package/dist/kempo/icons/create_new_folder.svg +0 -1
- package/dist/kempo/icons/credit_card.svg +0 -1
- package/dist/kempo/icons/login.svg +0 -1
- package/dist/kempo/icons/payments.svg +0 -1
- package/dist/kempo/icons/toll.svg +0 -1
- package/dist/kempo/icons/wallet.svg +0 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { basename, dirname } from 'path';
|
|
3
|
+
import scanDir from '../fs/scanDir.js';
|
|
4
|
+
import { parseEntries } from '../global-content/helpers.js';
|
|
5
|
+
import findMenuLocations from './findMenuLocations.js';
|
|
6
|
+
import findNavItems from './findNavItems.js';
|
|
7
|
+
import findPages from './findPages.js';
|
|
8
|
+
import { readStores, isMenuDefinition, isMenuItem } from './store.js';
|
|
9
|
+
import { parseLink } from './markup.js';
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
What an extension adds to a location from its own public/*.global.html. It is shown so the person
|
|
13
|
+
editing the menus sees the whole of what a location will contain, but it cannot be changed here:
|
|
14
|
+
it belongs to the extension.
|
|
15
|
+
*/
|
|
16
|
+
const readExtensionEntries = async dirs => {
|
|
17
|
+
const found = [];
|
|
18
|
+
for(const dir of dirs){
|
|
19
|
+
const owner = basename(dirname(dir));
|
|
20
|
+
const files = await scanDir(dir, dir, async (fullPath, entry) =>
|
|
21
|
+
!entry.isDirectory() && entry.name.endsWith('.global.html') ? fullPath : null);
|
|
22
|
+
for(const file of files){
|
|
23
|
+
for(const entry of parseEntries(await readFile(file, 'utf-8'))) found.push({ ...entry, owner });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return found;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const toItem = (entry, disabled, available = new Map()) => {
|
|
30
|
+
const link = entry.navItem ? null : parseLink(entry.markup);
|
|
31
|
+
return {
|
|
32
|
+
id: entry.id,
|
|
33
|
+
label: link?.label ?? entry.name,
|
|
34
|
+
href: link?.href ?? '',
|
|
35
|
+
icon: link?.icon ?? '',
|
|
36
|
+
newTab: link?.newTab ?? false,
|
|
37
|
+
editable: link !== null,
|
|
38
|
+
navItem: entry.navItem,
|
|
39
|
+
available: entry.navItem ? available.has(entry.navItem) : true,
|
|
40
|
+
priority: entry.priority,
|
|
41
|
+
owner: entry.owner,
|
|
42
|
+
disabled
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
The site's menus and the places in its theme that can show one.
|
|
48
|
+
|
|
49
|
+
A menu is `{ id, name, locations, items }`, its items in the order they render (highest priority
|
|
50
|
+
first). A location is `{ location, label, declared, menuId, extensionItems, candidates }`:
|
|
51
|
+
`menuId` is the menu shown there (or null), `extensionItems` what extensions put there themselves,
|
|
52
|
+
and `candidates` the plain links already placed there, which can be moved into a menu but are never
|
|
53
|
+
converted on their own. `declared` is false for a place the theme no longer has.
|
|
54
|
+
|
|
55
|
+
`pages` is every page a link can go to, the site's and those its extensions serve under their scopes
|
|
56
|
+
(see findPages.js), as { url, title, owner }.
|
|
57
|
+
|
|
58
|
+
`extensionDirs` overrides where extensions' content is read from, and `extensions` where their pages are.
|
|
59
|
+
*/
|
|
60
|
+
export default async ({ rootDir, extensionDirs, extensions }) => {
|
|
61
|
+
if(!rootDir){
|
|
62
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const [, declared] = await findMenuLocations({ rootDir });
|
|
66
|
+
const { active, disabled } = await readStores(rootDir);
|
|
67
|
+
const dirs = extensionDirs ?? await (await import('../extensions/contentDirs.js')).extensionPublicDirs();
|
|
68
|
+
const fromExtensions = await readExtensionEntries(dirs);
|
|
69
|
+
const navItems = (await findNavItems({ extensionDirs: dirs }))[1];
|
|
70
|
+
const available = new Map(navItems.map(item => [item.key, item]));
|
|
71
|
+
|
|
72
|
+
const menus = active.filter(isMenuDefinition).map(definition => ({
|
|
73
|
+
id: definition.id,
|
|
74
|
+
name: definition.name,
|
|
75
|
+
locations: definition.menuLocations,
|
|
76
|
+
items: [
|
|
77
|
+
...active.filter(e => isMenuItem(e) && e.menu === definition.id).map(e => toItem(e, false, available)),
|
|
78
|
+
...disabled.filter(e => isMenuItem(e) && e.menu === definition.id).map(e => toItem(e, true, available))
|
|
79
|
+
].sort((a, b) => b.priority - a.priority)
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const locations = declared.map(({ location, label }) => ({ location, label, declared: true }));
|
|
83
|
+
for(const menu of menus){
|
|
84
|
+
for(const location of menu.locations){
|
|
85
|
+
if(!locations.some(l => l.location === location)) locations.push({ location, label: location, declared: false });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for(const place of locations){
|
|
90
|
+
place.menuId = menus.find(m => m.locations.includes(place.location))?.id ?? null;
|
|
91
|
+
place.extensionItems = [
|
|
92
|
+
...active.filter(e => !e.menu && e.owner !== 'custom' && e.location === place.location),
|
|
93
|
+
...fromExtensions.filter(e => e.location === place.location)
|
|
94
|
+
].map(e => toItem(e, false)).sort((a, b) => b.priority - a.priority);
|
|
95
|
+
place.candidates = active
|
|
96
|
+
.filter(e => !e.menu && e.owner === 'custom' && e.location === place.location && parseLink(e.markup))
|
|
97
|
+
.map(e => toItem(e, false));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const [, pages] = await findPages({ rootDir, extensions });
|
|
101
|
+
|
|
102
|
+
return [null, { menus, locations, navItems: navItems.map(({ markup, ...item }) => item), pages: pages ?? [] }];
|
|
103
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const escapeHtml = value => String(value)
|
|
2
|
+
.replace(/&/g, '&')
|
|
3
|
+
.replace(/</g, '<')
|
|
4
|
+
.replace(/>/g, '>')
|
|
5
|
+
.replace(/"/g, '"');
|
|
6
|
+
|
|
7
|
+
const unescapeHtml = value => String(value)
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/&/g, '&');
|
|
12
|
+
|
|
13
|
+
const UNSAFE_HREF = /^\s*(javascript|data|vbscript):/i;
|
|
14
|
+
const ICON_NAME = /^[\w-]+$/;
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
A menu item is stored as nothing more than one link, so the entry stays plain global content that
|
|
18
|
+
any template renders without knowing about menus. This reads that link back into its fields, and
|
|
19
|
+
returns null for markup that is anything more than a link with an optional icon.
|
|
20
|
+
*/
|
|
21
|
+
export const parseLink = markup => {
|
|
22
|
+
const match = /^<a\s((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*)<\/a>$/.exec(String(markup || '').trim());
|
|
23
|
+
if(!match || /<a[\s>]/.test(match[2])) return null;
|
|
24
|
+
|
|
25
|
+
const attrs = {};
|
|
26
|
+
const attrRegex = /(\w[\w-]*)=(?:"([^"]*)"|'([^']*)')/g;
|
|
27
|
+
let a;
|
|
28
|
+
while((a = attrRegex.exec(match[1])) !== null) attrs[a[1]] = a[2] ?? a[3];
|
|
29
|
+
if(attrs.href === undefined) return null;
|
|
30
|
+
|
|
31
|
+
const icon = /^\s*<k-icon\s+name="([\w-]+)"\s*><\/k-icon>/.exec(match[2]);
|
|
32
|
+
const rest = icon ? match[2].slice(icon[0].length) : match[2];
|
|
33
|
+
if(/</.test(rest)) return null;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
label: unescapeHtml(rest.trim()),
|
|
37
|
+
href: unescapeHtml(attrs.href),
|
|
38
|
+
icon: icon ? icon[1] : '',
|
|
39
|
+
newTab: attrs.target === '_blank'
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
Returns [error, markup], with the same error shape as the rest of the server utils.
|
|
45
|
+
*/
|
|
46
|
+
export const buildLink = ({ label, href, icon, newTab }) => {
|
|
47
|
+
if(!String(label || '').trim()) return [{ code: 400, msg: 'Label is required' }, null];
|
|
48
|
+
if(!String(href || '').trim()) return [{ code: 400, msg: 'Link is required' }, null];
|
|
49
|
+
if(UNSAFE_HREF.test(href)) return [{ code: 400, msg: 'That link is not allowed' }, null];
|
|
50
|
+
if(icon && !ICON_NAME.test(icon)) return [{ code: 400, msg: 'Icon names use letters, numbers, dashes and underscores' }, null];
|
|
51
|
+
|
|
52
|
+
const target = newTab ? ' target="_blank" rel="noopener"' : '';
|
|
53
|
+
const iconMarkup = icon ? `<k-icon name="${icon}"></k-icon> ` : '';
|
|
54
|
+
return [null, `<a href="${escapeHtml(href.trim())}"${target}>${iconMarkup}${escapeHtml(label.trim())}</a>`];
|
|
55
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readStores, writeStores, isMenuItem } from './store.js';
|
|
2
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
`ids` are a menu's items in the order they should now render. They take over the priorities those
|
|
6
|
+
same items already held, so the priorities stay in the range they were in. Priorities that were
|
|
7
|
+
equal are separated by one each, because equal ones have no order to keep.
|
|
8
|
+
*/
|
|
9
|
+
export default async ({ rootDir, menuId, ids, extensionDirs }) => {
|
|
10
|
+
if(!rootDir){
|
|
11
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
12
|
+
}
|
|
13
|
+
if(!menuId || !Array.isArray(ids) || !ids.length){
|
|
14
|
+
return [{ code: 400, msg: 'A menu and the item order are required' }, null];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const stores = await readStores(rootDir);
|
|
18
|
+
const items = ids.map(id => [...stores.active, ...stores.disabled].find(e => e.id === id && isMenuItem(e) && e.menu === menuId));
|
|
19
|
+
if(items.includes(undefined) || new Set(ids).size !== ids.length){
|
|
20
|
+
return [{ code: 404, msg: 'Menu item not found' }, null];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const slots = items.map(e => e.priority).sort((a, b) => b - a);
|
|
24
|
+
for(let i = 1; i < slots.length; i++) slots[i] = Math.min(slots[i], slots[i - 1] - 1);
|
|
25
|
+
|
|
26
|
+
const now = new Date().toISOString();
|
|
27
|
+
items.forEach((entry, i) => {
|
|
28
|
+
if(entry.priority === slots[i]) return;
|
|
29
|
+
entry.priority = slots[i];
|
|
30
|
+
entry.updatedAt = now;
|
|
31
|
+
});
|
|
32
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
33
|
+
await triggerHook('globalContent:updated', { id: ids[0], updatedAt: now });
|
|
34
|
+
|
|
35
|
+
return [null, { ids }];
|
|
36
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { readStores, writeStores, menuHome, isMenuDefinition } from './store.js';
|
|
3
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Creates a menu, or renames one when `id` is given. A new menu is not shown anywhere until it is
|
|
7
|
+
assigned to a location.
|
|
8
|
+
*/
|
|
9
|
+
export default async ({ rootDir, id, name, author, extensionDirs }) => {
|
|
10
|
+
if(!rootDir){
|
|
11
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
12
|
+
}
|
|
13
|
+
if(!String(name || '').trim()){
|
|
14
|
+
return [{ code: 400, msg: 'Name is required' }, null];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const stores = await readStores(rootDir);
|
|
18
|
+
const now = new Date().toISOString();
|
|
19
|
+
|
|
20
|
+
if(id){
|
|
21
|
+
const definition = stores.active.find(e => e.id === id && isMenuDefinition(e));
|
|
22
|
+
if(!definition){
|
|
23
|
+
return [{ code: 404, msg: 'Menu not found' }, null];
|
|
24
|
+
}
|
|
25
|
+
definition.name = name.trim();
|
|
26
|
+
definition.updatedAt = now;
|
|
27
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
28
|
+
await triggerHook('globalContent:updated', { id, updatedAt: now });
|
|
29
|
+
return [null, { id }];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const menuId = crypto.randomUUID();
|
|
33
|
+
stores.active.push({
|
|
34
|
+
id: menuId,
|
|
35
|
+
name: name.trim(),
|
|
36
|
+
owner: 'custom',
|
|
37
|
+
author: author || '',
|
|
38
|
+
locked: false,
|
|
39
|
+
menu: menuId,
|
|
40
|
+
menuLocations: [],
|
|
41
|
+
menuCopyOf: '',
|
|
42
|
+
navItem: '',
|
|
43
|
+
extra: {},
|
|
44
|
+
location: menuHome(menuId),
|
|
45
|
+
priority: 0,
|
|
46
|
+
createdAt: now,
|
|
47
|
+
updatedAt: now,
|
|
48
|
+
markup: ''
|
|
49
|
+
});
|
|
50
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
51
|
+
await triggerHook('globalContent:created', { id: menuId, name: name.trim(), location: menuHome(menuId) });
|
|
52
|
+
|
|
53
|
+
return [null, { id: menuId }];
|
|
54
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { buildLink } from './markup.js';
|
|
3
|
+
import { readStores, writeStores, menuHome, isMenuItem, isMenuDefinition } from './store.js';
|
|
4
|
+
import findMenuLocations from './findMenuLocations.js';
|
|
5
|
+
import findNavItems from './findNavItems.js';
|
|
6
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Adds an item to a menu, or changes one when `id` is given. A new item goes to the end of the menu: its
|
|
10
|
+
priority is one below the lowest in that menu (items render highest first).
|
|
11
|
+
|
|
12
|
+
An item is a link (`label`, `href`, ...) or one an extension supplies, named by `navItem`
|
|
13
|
+
(`<extension>:<id>`, from findNavItems), which takes its label and markup from the extension.
|
|
14
|
+
|
|
15
|
+
A new item can name the `menuId` to join, or just the `location` it should show in. For a location,
|
|
16
|
+
it joins the menu shown there; when there is none, one is made, named after the location, and shown
|
|
17
|
+
there. That is what lets an item be added to "Main menu" without making a menu first.
|
|
18
|
+
*/
|
|
19
|
+
export default async ({ rootDir, id, menuId, location, navItem, label, href, icon, newTab, author, extensionDirs }) => {
|
|
20
|
+
if(!rootDir){
|
|
21
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let name;
|
|
25
|
+
let markup;
|
|
26
|
+
if(navItem){
|
|
27
|
+
const supplied = (await findNavItems({ extensionDirs }))[1].find(item => item.key === navItem);
|
|
28
|
+
if(!supplied){
|
|
29
|
+
return [{ code: 404, msg: 'That item is not available. Its extension may be disabled or removed.' }, null];
|
|
30
|
+
}
|
|
31
|
+
name = supplied.label;
|
|
32
|
+
markup = supplied.markup;
|
|
33
|
+
} else {
|
|
34
|
+
const [linkError, linkMarkup] = buildLink({ label, href, icon, newTab });
|
|
35
|
+
if(linkError){
|
|
36
|
+
return [linkError, null];
|
|
37
|
+
}
|
|
38
|
+
name = label.trim();
|
|
39
|
+
markup = linkMarkup;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const stores = await readStores(rootDir);
|
|
43
|
+
const now = new Date().toISOString();
|
|
44
|
+
|
|
45
|
+
if(id){
|
|
46
|
+
const entry = [...stores.active, ...stores.disabled].find(e => e.id === id && isMenuItem(e));
|
|
47
|
+
if(!entry){
|
|
48
|
+
return [{ code: 404, msg: 'Menu item not found' }, null];
|
|
49
|
+
}
|
|
50
|
+
if(entry.navItem){
|
|
51
|
+
return [{ code: 400, msg: 'An item from an extension cannot be edited. Delete it and add it again to change it.' }, null];
|
|
52
|
+
}
|
|
53
|
+
if(entry.locked){
|
|
54
|
+
return [{ code: 403, msg: 'This item is locked and cannot be edited' }, null];
|
|
55
|
+
}
|
|
56
|
+
entry.name = name;
|
|
57
|
+
entry.markup = markup;
|
|
58
|
+
entry.updatedAt = now;
|
|
59
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
60
|
+
await triggerHook('globalContent:updated', { id, updatedAt: now });
|
|
61
|
+
return [null, { id, updatedAt: now }];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let target = menuId
|
|
65
|
+
? stores.active.find(e => e.id === menuId && isMenuDefinition(e))
|
|
66
|
+
: stores.active.find(e => isMenuDefinition(e) && e.menuLocations.includes(location));
|
|
67
|
+
|
|
68
|
+
if(!target && !menuId && location){
|
|
69
|
+
const [, declared] = await findMenuLocations({ rootDir });
|
|
70
|
+
const place = declared.find(l => l.location === location);
|
|
71
|
+
if(!place){
|
|
72
|
+
return [{ code: 400, msg: 'That place is not a menu in the theme' }, null];
|
|
73
|
+
}
|
|
74
|
+
const newId = crypto.randomUUID();
|
|
75
|
+
target = {
|
|
76
|
+
id: newId,
|
|
77
|
+
name: place.label,
|
|
78
|
+
owner: 'custom',
|
|
79
|
+
author: author || '',
|
|
80
|
+
locked: false,
|
|
81
|
+
menu: newId,
|
|
82
|
+
menuLocations: [location],
|
|
83
|
+
menuCopyOf: '',
|
|
84
|
+
navItem: '',
|
|
85
|
+
extra: {},
|
|
86
|
+
location: menuHome(newId),
|
|
87
|
+
priority: 0,
|
|
88
|
+
createdAt: now,
|
|
89
|
+
updatedAt: now,
|
|
90
|
+
markup: ''
|
|
91
|
+
};
|
|
92
|
+
stores.active.push(target);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if(!target){
|
|
96
|
+
return [{ code: 404, msg: 'Menu not found' }, null];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const siblings = [...stores.active, ...stores.disabled].filter(e => isMenuItem(e) && e.menu === target.id);
|
|
100
|
+
const entry = {
|
|
101
|
+
id: crypto.randomUUID(),
|
|
102
|
+
name,
|
|
103
|
+
owner: 'custom',
|
|
104
|
+
author: author || '',
|
|
105
|
+
locked: false,
|
|
106
|
+
menu: target.id,
|
|
107
|
+
menuLocations: null,
|
|
108
|
+
menuCopyOf: '',
|
|
109
|
+
navItem: navItem || '',
|
|
110
|
+
extra: {},
|
|
111
|
+
location: menuHome(target.id),
|
|
112
|
+
priority: siblings.length ? Math.min(...siblings.map(e => e.priority)) - 1 : 100,
|
|
113
|
+
createdAt: now,
|
|
114
|
+
updatedAt: now,
|
|
115
|
+
markup
|
|
116
|
+
};
|
|
117
|
+
stores.active.push(entry);
|
|
118
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
119
|
+
await triggerHook('globalContent:created', { id: entry.id, name: entry.name, location: entry.location });
|
|
120
|
+
|
|
121
|
+
return [null, { id: entry.id, menuId: target.id, priority: entry.priority }];
|
|
122
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readStores, writeStores, isMenuItem } from './store.js';
|
|
2
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Hides an item from its menu without deleting it, or shows it again.
|
|
6
|
+
*/
|
|
7
|
+
export default async ({ rootDir, id, enabled, extensionDirs }) => {
|
|
8
|
+
if(!rootDir){
|
|
9
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
10
|
+
}
|
|
11
|
+
if(!id || typeof enabled !== 'boolean'){
|
|
12
|
+
return [{ code: 400, msg: 'An item and whether it is enabled are required' }, null];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const stores = await readStores(rootDir);
|
|
16
|
+
const [from, to] = enabled ? [stores.disabled, stores.active] : [stores.active, stores.disabled];
|
|
17
|
+
const index = from.findIndex(e => e.id === id && isMenuItem(e));
|
|
18
|
+
if(index === -1){
|
|
19
|
+
const already = to.some(e => e.id === id && isMenuItem(e));
|
|
20
|
+
return already ? [null, { id, enabled }] : [{ code: 404, msg: 'Menu item not found' }, null];
|
|
21
|
+
}
|
|
22
|
+
if(from[index].locked){
|
|
23
|
+
return [{ code: 403, msg: 'A locked item cannot be hidden' }, null];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
to.push(...from.splice(index, 1));
|
|
27
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
28
|
+
await triggerHook('globalContent:updated', { id, updatedAt: new Date().toISOString() });
|
|
29
|
+
|
|
30
|
+
return [null, { id, enabled }];
|
|
31
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries } from '../global-content/helpers.js';
|
|
5
|
+
import findNavItems from './findNavItems.js';
|
|
6
|
+
|
|
7
|
+
const read = async path => existsSync(path) ? parseEntries(await readFile(path, 'utf-8')) : [];
|
|
8
|
+
|
|
9
|
+
const write = async (path, entries) => {
|
|
10
|
+
if(!entries.length && !existsSync(path)) return;
|
|
11
|
+
await writeFile(path, entries.length ? serializeEntries(entries) + '\n' : '', 'utf-8');
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
Where a menu's own entries are filed. It is not a place any template has, so nothing is rendered
|
|
16
|
+
from them directly: what shows up on the site is the copies below.
|
|
17
|
+
*/
|
|
18
|
+
export const menuHome = menuId => `menu:${menuId}`;
|
|
19
|
+
|
|
20
|
+
export const isMenuDefinition = entry => entry.menuLocations !== null;
|
|
21
|
+
|
|
22
|
+
export const isMenuItem = entry => Boolean(entry.menu) && entry.menuLocations === null && !entry.menuCopyOf;
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
The page shows a menu in as many places as it is assigned, but a piece of global content fills one
|
|
26
|
+
location. So each enabled item gets a copy in every location its menu is assigned to, and the copies
|
|
27
|
+
are what a template renders. They are rebuilt from the items on every write, never edited, which is
|
|
28
|
+
what keeps a menu's places in step with its items.
|
|
29
|
+
|
|
30
|
+
An item an extension supplies keeps the extension's markup as it was when last written, so it renders
|
|
31
|
+
even though kempo-server cannot look it up at render time. `navItems` (name to item) is how it is kept
|
|
32
|
+
current: the markup is refreshed from the extension, and the item gets no copies, so it disappears from
|
|
33
|
+
the site, while the extension is not available. The item itself stays in its menu, so it returns with
|
|
34
|
+
the extension.
|
|
35
|
+
*/
|
|
36
|
+
const rebuildCopies = (active, navItems) => {
|
|
37
|
+
const entries = active.filter(e => !e.menuCopyOf);
|
|
38
|
+
const copies = [];
|
|
39
|
+
|
|
40
|
+
for(const entry of entries.filter(e => e.navItem && navItems?.has(e.navItem))){
|
|
41
|
+
const current = navItems.get(entry.navItem);
|
|
42
|
+
entry.name = current.label;
|
|
43
|
+
entry.markup = current.markup;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for(const definition of entries.filter(isMenuDefinition)){
|
|
47
|
+
const items = entries.filter(e => isMenuItem(e) && e.menu === definition.id && !(e.navItem && navItems && !navItems.has(e.navItem)));
|
|
48
|
+
for(const location of definition.menuLocations){
|
|
49
|
+
for(const item of items){
|
|
50
|
+
copies.push({
|
|
51
|
+
...item,
|
|
52
|
+
id: `${item.id}@${location}`,
|
|
53
|
+
locked: false,
|
|
54
|
+
location,
|
|
55
|
+
menuCopyOf: item.id,
|
|
56
|
+
navItem: '',
|
|
57
|
+
extra: {}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...entries, ...copies];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
Both files a site's global content lives in: what is showing, and what has been switched off.
|
|
67
|
+
An item can be in either, so menu operations always look at both. The copies are left out of
|
|
68
|
+
`active`, as they only exist to be rendered.
|
|
69
|
+
*/
|
|
70
|
+
export const readStores = async rootDir => ({
|
|
71
|
+
active: (await read(join(rootDir, GLOBAL_FILE))).filter(e => !e.menuCopyOf),
|
|
72
|
+
disabled: await read(join(rootDir, GLOBAL_DISABLED_FILE))
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const writeStores = async (rootDir, { active, disabled }, extensionDirs) => {
|
|
76
|
+
const hasNavItems = [...active, ...disabled].some(e => e.navItem);
|
|
77
|
+
const navItems = hasNavItems ? new Map((await findNavItems({ extensionDirs }))[1].map(item => [item.key, item])) : null;
|
|
78
|
+
await write(join(rootDir, GLOBAL_FILE), rebuildCopies(active, navItems));
|
|
79
|
+
await write(join(rootDir, GLOBAL_DISABLED_FILE), disabled);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
Brings every menu up to date with the extensions that are installed now. Run when extensions are
|
|
84
|
+
installed, enabled, disabled or removed, so what an extension supplied leaves the site with it.
|
|
85
|
+
*/
|
|
86
|
+
export const resyncMenus = async (rootDir, extensionDirs) => {
|
|
87
|
+
const stores = await readStores(rootDir);
|
|
88
|
+
if(![...stores.active, ...stores.disabled].some(e => e.navItem)) return;
|
|
89
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
90
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import parseDuration from '../realtime/duration.js';
|
|
2
|
+
|
|
3
|
+
export const MIN_INTERVAL_MS = 1000;
|
|
4
|
+
|
|
5
|
+
/* How long a process may hold a job before another may take it over, in case the first one died mid-run. */
|
|
6
|
+
export const LEASE_MS = 5 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
export const jobId = (owner, name) => `${owner}:${name}`;
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
Checks the `jobs` an extension declares: [{ name, every, callback }]. `every` is a number of
|
|
12
|
+
milliseconds or a string such as "30s", "1m" or "6h". Returns [error, jobs] where each job has
|
|
13
|
+
`everyMs` filled in.
|
|
14
|
+
*/
|
|
15
|
+
export const normalizeJobs = jobs => {
|
|
16
|
+
if(jobs === undefined || jobs === null) return [null, []];
|
|
17
|
+
if(!Array.isArray(jobs)) return [{ code: 400, msg: 'jobs must be a list' }, null];
|
|
18
|
+
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const result = [];
|
|
21
|
+
for(const job of jobs){
|
|
22
|
+
const name = String(job?.name ?? '');
|
|
23
|
+
if(!/^[a-z0-9][a-z0-9-]*$/.test(name)) return [{ code: 400, msg: `Job name "${name}" must be lowercase letters, numbers and dashes` }, null];
|
|
24
|
+
if(seen.has(name)) return [{ code: 400, msg: `Job "${name}" is declared twice` }, null];
|
|
25
|
+
seen.add(name);
|
|
26
|
+
|
|
27
|
+
const everyMs = parseDuration(job.every);
|
|
28
|
+
if(everyMs === null || everyMs < MIN_INTERVAL_MS) return [{ code: 400, msg: `Job "${name}" needs an "every" of at least one second, such as "1m"` }, null];
|
|
29
|
+
|
|
30
|
+
const callback = String(job.callback ?? '');
|
|
31
|
+
if(!callback.startsWith('./')) return [{ code: 400, msg: `Job "${name}" needs a "callback" path starting with ./` }, null];
|
|
32
|
+
|
|
33
|
+
result.push({ name, every: job.every, everyMs, callback });
|
|
34
|
+
}
|
|
35
|
+
return [null, result];
|
|
36
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { scheduledJob } from '../../db/schema.js';
|
|
3
|
+
import { asc } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
export default async () => {
|
|
6
|
+
try {
|
|
7
|
+
return [null, await db.select().from(scheduledJob).orderBy(asc(scheduledJob.owner), asc(scheduledJob.name))];
|
|
8
|
+
} catch(error) {
|
|
9
|
+
return [{ code: 500, msg: 'Failed to list jobs' }, null];
|
|
10
|
+
}
|
|
11
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { scheduledJob } from '../../db/schema.js';
|
|
3
|
+
import { and, eq, notInArray } from 'drizzle-orm';
|
|
4
|
+
import { normalizeJobs, jobId } from './helpers.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Makes an extension's schedule match what it declares: adds new jobs, refreshes the interval and
|
|
8
|
+
callback of existing ones (keeping when they are next due), and removes the ones no longer declared.
|
|
9
|
+
Safe to call from install and again from update.
|
|
10
|
+
*/
|
|
11
|
+
export default async ({ owner, jobs }) => {
|
|
12
|
+
if(!owner){
|
|
13
|
+
return [{ code: 400, msg: 'Owner is required' }, null];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [invalid, declared] = normalizeJobs(jobs);
|
|
17
|
+
if(invalid){
|
|
18
|
+
return [invalid, null];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const now = new Date();
|
|
23
|
+
for(const job of declared){
|
|
24
|
+
const row = { owner, name: job.name, everyMs: job.everyMs, callback: job.callback };
|
|
25
|
+
await db.insert(scheduledJob)
|
|
26
|
+
.values({ id: jobId(owner, job.name), ...row, nextRunAt: now, createdAt: now })
|
|
27
|
+
.onConflictDoUpdate({ target: scheduledJob.id, set: row });
|
|
28
|
+
}
|
|
29
|
+
const names = declared.map(job => job.name);
|
|
30
|
+
await db.delete(scheduledJob).where(names.length
|
|
31
|
+
? and(eq(scheduledJob.owner, owner), notInArray(scheduledJob.name, names))
|
|
32
|
+
: eq(scheduledJob.owner, owner));
|
|
33
|
+
return [null, { registered: declared.length }];
|
|
34
|
+
} catch(error) {
|
|
35
|
+
return [{ code: 500, msg: 'Failed to register jobs' }, null];
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { scheduledJob } from '../../db/schema.js';
|
|
3
|
+
import { and, eq, lte, or, isNull, asc } from 'drizzle-orm';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { pathToFileURL } from 'url';
|
|
6
|
+
import { getDisabledExtensionNames } from '../extensions/scopeCache.js';
|
|
7
|
+
import { LEASE_MS, jobId } from './helpers.js';
|
|
8
|
+
|
|
9
|
+
const resolveHandler = (owner, callback) => join(process.cwd(), 'node_modules', owner, callback);
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
Takes a job for this process: one conditional UPDATE, so of any number of processes asking at once
|
|
13
|
+
exactly one gets the row. It then moves `nextRunAt` on by an interval. `force` skips the "is it due"
|
|
14
|
+
test (for running a job by hand) but never the lease.
|
|
15
|
+
*/
|
|
16
|
+
const claim = async (id, { force = false } = {}) => {
|
|
17
|
+
const now = new Date();
|
|
18
|
+
const conditions = [eq(scheduledJob.id, id), or(isNull(scheduledJob.lockedUntil), lte(scheduledJob.lockedUntil, now))];
|
|
19
|
+
if(!force) conditions.push(lte(scheduledJob.nextRunAt, now));
|
|
20
|
+
|
|
21
|
+
const [row] = await db.update(scheduledJob)
|
|
22
|
+
.set({ lockedUntil: new Date(now.getTime() + LEASE_MS) })
|
|
23
|
+
.where(and(...conditions))
|
|
24
|
+
.returning();
|
|
25
|
+
if(!row) return null;
|
|
26
|
+
|
|
27
|
+
if(!force){
|
|
28
|
+
await db.update(scheduledJob).set({ nextRunAt: new Date(now.getTime() + row.everyMs) }).where(eq(scheduledJob.id, id));
|
|
29
|
+
}
|
|
30
|
+
return row;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/* Runs a claimed job's handler, then releases the lease and records how it went. A throwing job never stops anything else. */
|
|
34
|
+
const execute = async row => {
|
|
35
|
+
let status = 'ok';
|
|
36
|
+
let message = null;
|
|
37
|
+
try {
|
|
38
|
+
const mod = await import(pathToFileURL(resolveHandler(row.owner, row.callback)).href);
|
|
39
|
+
await (mod.default || mod)({ owner: row.owner, name: row.name });
|
|
40
|
+
} catch(error) {
|
|
41
|
+
status = 'error';
|
|
42
|
+
message = String(error?.message ?? error).slice(0, 500);
|
|
43
|
+
console.error(`[kempo] Job "${row.name}" (${row.owner}) failed: ${message}`);
|
|
44
|
+
}
|
|
45
|
+
await db.update(scheduledJob)
|
|
46
|
+
.set({ lockedUntil: null, lastRunAt: new Date(), lastStatus: status, lastError: message })
|
|
47
|
+
.where(eq(scheduledJob.id, row.id))
|
|
48
|
+
.catch(() => {});
|
|
49
|
+
return { status, error: message };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
Runs every job that is due and not held by another process. A disabled extension's jobs stay
|
|
54
|
+
registered but never run, the same as its hooks. Returns the ones this process ran.
|
|
55
|
+
*/
|
|
56
|
+
export const runDueJobs = async () => {
|
|
57
|
+
try {
|
|
58
|
+
const disabled = await getDisabledExtensionNames();
|
|
59
|
+
const candidates = await db.select().from(scheduledJob).where(lte(scheduledJob.nextRunAt, new Date())).orderBy(asc(scheduledJob.nextRunAt));
|
|
60
|
+
const ran = [];
|
|
61
|
+
for(const candidate of candidates){
|
|
62
|
+
if(disabled.has(candidate.owner)) continue;
|
|
63
|
+
const row = await claim(candidate.id);
|
|
64
|
+
if(!row) continue;
|
|
65
|
+
ran.push({ owner: row.owner, name: row.name, ...(await execute(row)) });
|
|
66
|
+
}
|
|
67
|
+
return [null, ran];
|
|
68
|
+
} catch(error) {
|
|
69
|
+
return [{ code: 500, msg: 'Failed to run jobs' }, null];
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/* Runs one job now, whatever the schedule says. Refused with 409 while another process is running it. */
|
|
74
|
+
export const runJob = async ({ owner, name } = {}) => {
|
|
75
|
+
if(!owner || !name){
|
|
76
|
+
return [{ code: 400, msg: 'Owner and name are required' }, null];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const id = jobId(owner, name);
|
|
81
|
+
const [exists] = await db.select({ id: scheduledJob.id }).from(scheduledJob).where(eq(scheduledJob.id, id));
|
|
82
|
+
if(!exists) return [{ code: 404, msg: 'Job not found' }, null];
|
|
83
|
+
if((await getDisabledExtensionNames()).has(owner)) return [{ code: 409, msg: 'The extension is disabled' }, null];
|
|
84
|
+
const row = await claim(id, { force: true });
|
|
85
|
+
if(!row) return [{ code: 409, msg: 'The job is already running' }, null];
|
|
86
|
+
return [null, await execute(row)];
|
|
87
|
+
} catch(error) {
|
|
88
|
+
return [{ code: 500, msg: 'Failed to run the job' }, null];
|
|
89
|
+
}
|
|
90
|
+
};
|