kempo 4.6.2 → 4.8.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 +8 -1
- 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 +18 -33
- package/dist/admin/components/Dashboard.js +2 -2
- package/dist/admin/components/Dashboard.js.map +1 -1
- package/dist/admin/components/EmailTemplates.js +25 -0
- package/dist/admin/components/EmailTemplates.js.map +1 -0
- package/dist/admin/components/Emails.js +49 -0
- package/dist/admin/components/Emails.js.map +1 -0
- package/dist/admin/components/Menus.js +103 -0
- package/dist/admin/components/Menus.js.map +1 -0
- package/dist/admin/components/settings/AdminSettings.js +7 -0
- package/dist/admin/components/settings/AdminSettings.js.map +1 -0
- package/dist/admin/components/settings/BooleanSetting.js +2 -0
- package/dist/admin/components/settings/BooleanSetting.js.map +1 -0
- package/dist/admin/components/settings/JsonSetting.js +2 -0
- package/dist/admin/components/settings/JsonSetting.js.map +1 -0
- package/dist/admin/components/settings/NumberSetting.js +2 -0
- package/dist/admin/components/settings/NumberSetting.js.map +1 -0
- package/dist/admin/components/settings/SecretSetting.js +2 -0
- package/dist/admin/components/settings/SecretSetting.js.map +1 -0
- package/dist/admin/components/settings/Setting.js +2 -0
- package/dist/admin/components/settings/Setting.js.map +1 -0
- package/dist/admin/components/settings/SettingEditor.js +2 -0
- package/dist/admin/components/settings/SettingEditor.js.map +1 -0
- package/dist/admin/components/settings/SettingsPanel.js +24 -0
- package/dist/admin/components/settings/SettingsPanel.js.map +1 -0
- package/dist/admin/components/settings/StringSetting.js +2 -0
- package/dist/admin/components/settings/StringSetting.js.map +1 -0
- package/dist/admin/components/settings/humanize.js +2 -0
- package/dist/admin/components/settings/humanize.js.map +1 -0
- package/dist/admin/content/menus/index.page.html +11 -0
- package/dist/admin/emails/index.page.html +14 -0
- package/dist/admin/icons/mail.svg +1 -0
- package/dist/admin/nav.fragment.html +12 -0
- package/dist/admin/settings/[owner]/[name]/index.page.html +1 -1
- package/dist/admin/settings/index.page.html +6 -289
- package/dist/kempo/api/email-templates/GET.js +2 -0
- package/dist/kempo/api/email-templates/GET.js.map +1 -0
- package/dist/kempo/api/email-templates/file/DELETE.js +2 -0
- package/dist/kempo/api/email-templates/file/DELETE.js.map +1 -0
- package/dist/kempo/api/email-templates/file/GET.js +2 -0
- package/dist/kempo/api/email-templates/file/GET.js.map +1 -0
- package/dist/kempo/api/email-templates/file/PUT.js +2 -0
- package/dist/kempo/api/email-templates/file/PUT.js.map +1 -0
- package/dist/kempo/api/email-templates/preview/GET.js +2 -0
- package/dist/kempo/api/email-templates/preview/GET.js.map +1 -0
- package/dist/kempo/api/emails/GET.js +2 -0
- package/dist/kempo/api/emails/GET.js.map +1 -0
- package/dist/kempo/api/emails/[id]/GET.js +2 -0
- package/dist/kempo/api/emails/[id]/GET.js.map +1 -0
- package/dist/kempo/api/emails/[id]/retry/POST.js +2 -0
- package/dist/kempo/api/emails/[id]/retry/POST.js.map +1 -0
- package/dist/kempo/api/emails/test/POST.js +2 -0
- package/dist/kempo/api/emails/test/POST.js.map +1 -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/settings/[owner]/[name]/POST.js +1 -1
- package/dist/kempo/api/settings/[owner]/[name]/POST.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 +69 -12
- package/package.json +3 -4
- package/server/db/schema.js +56 -0
- package/server/sdk.js +26 -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/defaultSender.js +2 -0
- package/server/utils/email/dispatchEmail.js +56 -0
- package/server/utils/email/emailSettings.js +21 -0
- package/server/utils/email/emailTemplatePaths.js +36 -0
- package/server/utils/email/getEmail.js +14 -0
- package/server/utils/email/getSenderEmail.js +11 -0
- package/server/utils/email/getSenderFrom.js +18 -0
- package/server/utils/email/isValidSender.js +4 -0
- package/server/utils/email/listEmailTemplates.js +23 -0
- package/server/utils/email/listEmails.js +35 -0
- package/server/utils/email/maintainEmails.js +46 -0
- package/server/utils/email/pruneEmails.js +29 -0
- package/server/utils/email/readEmailTemplate.js +28 -0
- package/server/utils/email/renderEmailTemplate.js +63 -0
- package/server/utils/email/resetEmailTemplate.js +15 -0
- package/server/utils/email/retryEmail.js +21 -0
- package/server/utils/email/saveEmailTemplate.js +38 -0
- package/server/utils/email/sendEmail.js +22 -32
- 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/hooks/triggerHook.js +17 -4
- 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/listSettings.js +10 -5
- package/server/utils/settings/secretCrypto.js +5 -1
- package/server/utils/settings/setSetting.js +4 -0
- package/server/utils/settings/settingRules.js +21 -0
- package/server/utils/settings/systemSettings.js +40 -0
- package/server/utils/setup/seedDatabase.js +5 -60
- package/server/utils/setup/systemPermissions.js +75 -0
- 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,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
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { runDueJobs } from './runJobs.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
One ticker per process, held on a Symbol.for global so that the middleware and an extension reach the
|
|
5
|
+
same one even if kempo was resolved twice. Each tick only asks the database what is due; the lease on
|
|
6
|
+
each job row is what stops several processes running the same job. Set KEMPO_SCHEDULER=off to keep a
|
|
7
|
+
process from running jobs.
|
|
8
|
+
*/
|
|
9
|
+
const SCHEDULER = Symbol.for('kempo.scheduler');
|
|
10
|
+
|
|
11
|
+
export const TICK_MS = 15000;
|
|
12
|
+
|
|
13
|
+
export const startScheduler = ({ tickMs = TICK_MS } = {}) => {
|
|
14
|
+
if(globalThis[SCHEDULER] || process.env.KEMPO_SCHEDULER === 'off') return false;
|
|
15
|
+
|
|
16
|
+
let running = false;
|
|
17
|
+
const timer = setInterval(async () => {
|
|
18
|
+
if(running) return;
|
|
19
|
+
running = true;
|
|
20
|
+
try {
|
|
21
|
+
const [error] = await runDueJobs();
|
|
22
|
+
if(error) console.error(`[kempo] Scheduler: ${error.msg}`);
|
|
23
|
+
} finally {
|
|
24
|
+
running = false;
|
|
25
|
+
}
|
|
26
|
+
}, tickMs);
|
|
27
|
+
timer.unref?.();
|
|
28
|
+
globalThis[SCHEDULER] = timer;
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const stopScheduler = () => {
|
|
33
|
+
if(!globalThis[SCHEDULER]) return false;
|
|
34
|
+
clearInterval(globalThis[SCHEDULER]);
|
|
35
|
+
globalThis[SCHEDULER] = null;
|
|
36
|
+
return true;
|
|
37
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { scheduledJob } from '../../db/schema.js';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
export default async ({ owner }) => {
|
|
6
|
+
if(!owner){
|
|
7
|
+
return [{ code: 400, msg: 'Owner is required' }, null];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
await db.delete(scheduledJob).where(eq(scheduledJob.owner, owner));
|
|
12
|
+
return [null, { success: true }];
|
|
13
|
+
} catch(error) {
|
|
14
|
+
return [{ code: 500, msg: 'Failed to remove jobs' }, null];
|
|
15
|
+
}
|
|
16
|
+
};
|