kempo 4.6.1 → 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.
Files changed (188) hide show
  1. package/README.md +5 -0
  2. package/app-public/account/index.page.html +2 -0
  3. package/app-public/nav.fragment.html +2 -1
  4. package/app-templates/emails/email.template.html +14 -0
  5. package/app-templates/emails/reset-password.page.html +19 -0
  6. package/app-templates/emails/test.page.html +21 -0
  7. package/app-templates/emails/verify-email.page.html +18 -0
  8. package/bin/cli.js +3 -0
  9. package/dist/admin/components/Dashboard.js +2 -2
  10. package/dist/admin/components/Dashboard.js.map +1 -1
  11. package/dist/admin/components/Menus.js +103 -0
  12. package/dist/admin/components/Menus.js.map +1 -0
  13. package/dist/admin/components/tableControls/ExtensionDetails.js +1 -1
  14. package/dist/admin/components/tableControls/ExtensionDetails.js.map +1 -1
  15. package/dist/admin/content/menus/index.page.html +11 -0
  16. package/dist/admin/extensions/browse/index.page.html +4 -1
  17. package/dist/admin/extensions/index.page.html +1 -0
  18. package/dist/admin/nav.fragment.html +6 -0
  19. package/dist/kempo/api/extensions/POST.js +1 -1
  20. package/dist/kempo/api/extensions/POST.js.map +1 -1
  21. package/dist/kempo/api/extensions/known/GET.js +1 -1
  22. package/dist/kempo/api/extensions/known/GET.js.map +1 -1
  23. package/dist/kempo/api/extensions/rating/PUT.js +1 -1
  24. package/dist/kempo/api/extensions/rating/PUT.js.map +1 -1
  25. package/dist/kempo/api/fragments/DELETE.js +1 -1
  26. package/dist/kempo/api/fragments/DELETE.js.map +1 -1
  27. package/dist/kempo/api/fragments/GET.js +1 -1
  28. package/dist/kempo/api/fragments/GET.js.map +1 -1
  29. package/dist/kempo/api/fragments/POST.js +1 -1
  30. package/dist/kempo/api/fragments/POST.js.map +1 -1
  31. package/dist/kempo/api/fragments/disable/PUT.js +1 -1
  32. package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
  33. package/dist/kempo/api/fragments/enable/PUT.js +1 -1
  34. package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
  35. package/dist/kempo/api/fragments/file/GET.js +1 -1
  36. package/dist/kempo/api/fragments/file/GET.js.map +1 -1
  37. package/dist/kempo/api/fragments/file/PATCH.js +1 -1
  38. package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
  39. package/dist/kempo/api/fragments/file/PUT.js +1 -1
  40. package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
  41. package/dist/kempo/api/fragments/lock/PUT.js +1 -1
  42. package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
  43. package/dist/kempo/api/globals/DELETE.js +1 -1
  44. package/dist/kempo/api/globals/DELETE.js.map +1 -1
  45. package/dist/kempo/api/globals/GET.js +1 -1
  46. package/dist/kempo/api/globals/GET.js.map +1 -1
  47. package/dist/kempo/api/globals/POST.js +1 -1
  48. package/dist/kempo/api/globals/POST.js.map +1 -1
  49. package/dist/kempo/api/globals/disable/PUT.js +1 -1
  50. package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
  51. package/dist/kempo/api/globals/enable/PUT.js +1 -1
  52. package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
  53. package/dist/kempo/api/globals/entry/GET.js +1 -1
  54. package/dist/kempo/api/globals/entry/GET.js.map +1 -1
  55. package/dist/kempo/api/globals/entry/PUT.js +1 -1
  56. package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
  57. package/dist/kempo/api/globals/lock/PUT.js +1 -1
  58. package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
  59. package/dist/kempo/api/menus/DELETE.js +2 -0
  60. package/dist/kempo/api/menus/DELETE.js.map +1 -0
  61. package/dist/kempo/api/menus/GET.js +2 -0
  62. package/dist/kempo/api/menus/GET.js.map +1 -0
  63. package/dist/kempo/api/menus/POST.js +2 -0
  64. package/dist/kempo/api/menus/POST.js.map +1 -0
  65. package/dist/kempo/api/menus/adopt/PUT.js +2 -0
  66. package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
  67. package/dist/kempo/api/menus/items/DELETE.js +2 -0
  68. package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
  69. package/dist/kempo/api/menus/items/POST.js +2 -0
  70. package/dist/kempo/api/menus/items/POST.js.map +1 -0
  71. package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
  72. package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
  73. package/dist/kempo/api/menus/items/order/PUT.js +2 -0
  74. package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
  75. package/dist/kempo/api/menus/location/PUT.js +2 -0
  76. package/dist/kempo/api/menus/location/PUT.js.map +1 -0
  77. package/dist/kempo/api/pages/DELETE.js +1 -1
  78. package/dist/kempo/api/pages/DELETE.js.map +1 -1
  79. package/dist/kempo/api/pages/GET.js +1 -1
  80. package/dist/kempo/api/pages/GET.js.map +1 -1
  81. package/dist/kempo/api/pages/POST.js +1 -1
  82. package/dist/kempo/api/pages/POST.js.map +1 -1
  83. package/dist/kempo/api/pages/directories/GET.js +1 -1
  84. package/dist/kempo/api/pages/directories/GET.js.map +1 -1
  85. package/dist/kempo/api/pages/disable/PUT.js +1 -1
  86. package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
  87. package/dist/kempo/api/pages/enable/PUT.js +1 -1
  88. package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
  89. package/dist/kempo/api/pages/file/GET.js +1 -1
  90. package/dist/kempo/api/pages/file/GET.js.map +1 -1
  91. package/dist/kempo/api/pages/file/PATCH.js +1 -1
  92. package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
  93. package/dist/kempo/api/pages/file/PUT.js +1 -1
  94. package/dist/kempo/api/pages/file/PUT.js.map +1 -1
  95. package/dist/kempo/api/pages/lock/PUT.js +1 -1
  96. package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
  97. package/dist/kempo/api/pages/rescan/POST.js +1 -1
  98. package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
  99. package/dist/kempo/api/pages/search/GET.js +1 -1
  100. package/dist/kempo/api/pages/search/GET.js.map +1 -1
  101. package/dist/kempo/api/pages/templates/GET.js +1 -1
  102. package/dist/kempo/api/pages/templates/GET.js.map +1 -1
  103. package/dist/kempo/api/templates/DELETE.js +1 -1
  104. package/dist/kempo/api/templates/DELETE.js.map +1 -1
  105. package/dist/kempo/api/templates/GET.js +1 -1
  106. package/dist/kempo/api/templates/GET.js.map +1 -1
  107. package/dist/kempo/api/templates/POST.js +1 -1
  108. package/dist/kempo/api/templates/POST.js.map +1 -1
  109. package/dist/kempo/api/templates/disable/PUT.js +1 -1
  110. package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
  111. package/dist/kempo/api/templates/enable/PUT.js +1 -1
  112. package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
  113. package/dist/kempo/api/templates/file/GET.js +1 -1
  114. package/dist/kempo/api/templates/file/GET.js.map +1 -1
  115. package/dist/kempo/api/templates/file/PATCH.js +1 -1
  116. package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
  117. package/dist/kempo/api/templates/file/PUT.js +1 -1
  118. package/dist/kempo/api/templates/file/PUT.js.map +1 -1
  119. package/dist/kempo/api/templates/lock/PUT.js +1 -1
  120. package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
  121. package/dist/kempo/components/DashboardWidget.js +4 -4
  122. package/dist/kempo/components/DashboardWidget.js.map +1 -1
  123. package/dist/kempo/icons/campaign.svg +1 -0
  124. package/dist/kempo/icons/fields.svg +1 -0
  125. package/dist/kempo/icons/open_in_new.svg +1 -0
  126. package/dist/kempo/sdk.js +1 -1
  127. package/dist/kempo/sdk.js.map +1 -1
  128. package/middleware/kempo.js +48 -12
  129. package/package.json +2 -2
  130. package/server/db/schema.js +24 -0
  131. package/server/sdk.js +21 -0
  132. package/server/utils/auth/requestPasswordReset.js +9 -3
  133. package/server/utils/auth/verifyEmail.js +10 -1
  134. package/server/utils/dashboard/coreWidgets.js +1 -0
  135. package/server/utils/dashboard/helpers.js +6 -0
  136. package/server/utils/email/renderEmailTemplate.js +63 -0
  137. package/server/utils/email/sendEmailFromTemplate.js +9 -27
  138. package/server/utils/extensions/aliases.js +71 -0
  139. package/server/utils/extensions/checkClaims.js +32 -0
  140. package/server/utils/extensions/claims.js +54 -0
  141. package/server/utils/extensions/createTablesFromSchema.js +13 -20
  142. package/server/utils/extensions/enableExtensionTree.js +8 -0
  143. package/server/utils/extensions/{getMarketplaceClient.js → getSiteMarketplaceClient.js} +1 -1
  144. package/server/utils/extensions/installExtension.js +32 -0
  145. package/server/utils/extensions/installFromCatalog.js +1 -1
  146. package/server/utils/extensions/installPackage.js +1 -1
  147. package/server/utils/extensions/{catalogClient.js → marketplaceClient.js} +4 -1
  148. package/server/utils/extensions/readKempoConfig.js +2 -2
  149. package/server/utils/extensions/scopeCache.js +14 -1
  150. package/server/utils/extensions/scopes.js +72 -0
  151. package/server/utils/extensions/uninstallExtensionTree.js +2 -1
  152. package/server/utils/extensions/updateExtension.js +35 -0
  153. package/server/utils/global-content/createGlobalContent.js +8 -18
  154. package/server/utils/global-content/deleteGlobalContent.js +1 -16
  155. package/server/utils/global-content/disableGlobalContent.js +1 -16
  156. package/server/utils/global-content/enableGlobalContent.js +1 -16
  157. package/server/utils/global-content/helpers.js +40 -6
  158. package/server/utils/global-content/listGlobalContent.js +2 -2
  159. package/server/utils/global-content/setGlobalContentLocked.js +1 -16
  160. package/server/utils/global-content/updateGlobalContent.js +1 -16
  161. package/server/utils/menus/adoptMenuItem.js +58 -0
  162. package/server/utils/menus/assignMenuLocation.js +47 -0
  163. package/server/utils/menus/deleteMenu.js +27 -0
  164. package/server/utils/menus/deleteMenuItems.js +27 -0
  165. package/server/utils/menus/findMenuLocations.js +41 -0
  166. package/server/utils/menus/findNavItems.js +74 -0
  167. package/server/utils/menus/findPages.js +73 -0
  168. package/server/utils/menus/listMenus.js +103 -0
  169. package/server/utils/menus/markup.js +55 -0
  170. package/server/utils/menus/reorderMenuItems.js +36 -0
  171. package/server/utils/menus/saveMenu.js +54 -0
  172. package/server/utils/menus/saveMenuItem.js +122 -0
  173. package/server/utils/menus/setMenuItemEnabled.js +31 -0
  174. package/server/utils/menus/store.js +90 -0
  175. package/server/utils/scheduler/helpers.js +36 -0
  176. package/server/utils/scheduler/listJobs.js +11 -0
  177. package/server/utils/scheduler/registerJobs.js +37 -0
  178. package/server/utils/scheduler/runJobs.js +90 -0
  179. package/server/utils/scheduler/scheduler.js +37 -0
  180. package/server/utils/scheduler/unregisterJobs.js +16 -0
  181. package/server/utils/settings/ensureEncryptionKey.js +132 -0
  182. package/server/utils/settings/secretCrypto.js +5 -1
  183. package/dist/kempo/icons/create_new_folder.svg +0 -1
  184. package/dist/kempo/icons/credit_card.svg +0 -1
  185. package/dist/kempo/icons/login.svg +0 -1
  186. package/dist/kempo/icons/payments.svg +0 -1
  187. package/dist/kempo/icons/toll.svg +0 -1
  188. package/dist/kempo/icons/wallet.svg +0 -1
@@ -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
+ };
@@ -0,0 +1,132 @@
1
+ import { readFile, writeFile, chmod } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import crypto from 'crypto';
4
+ import { and, eq, isNotNull, ne, sql } from 'drizzle-orm';
5
+ import db from '../../db/index.js';
6
+ import { setting } from '../../db/schema.js';
7
+
8
+ /*
9
+ Makes sure SETTINGS_ENCRYPTION_KEY exists before anything needs it, so a site never has to set one up by
10
+ hand. In order:
11
+
12
+ 1. the environment, when it has one (a secret manager, docker compose, the host). It always wins
13
+ 2. the site's `.env` file, for a key written after the process started
14
+ 3. a new random key, but only when nothing is encrypted yet. Written to `.env` (owner-only, and a
15
+ .gitignore that names it is kept honest), then set for this process, so there is no restart
16
+
17
+ A key is never generated while the database already holds encrypted settings: a new one would make every
18
+ one of them unreadable. That case is an error that says so. A key is also never kept only in memory:
19
+ when `.env` cannot be written the answer is an error, because a key that vanishes on the next restart
20
+ is worse than none.
21
+
22
+ Anyone with shell access to the server can read the key in `.env`. Back it up with the database: losing
23
+ it makes the stored credentials unrecoverable (they can be typed in again, nothing else is lost).
24
+ */
25
+
26
+ const NAME = 'SETTINGS_ENCRYPTION_KEY';
27
+ const VALID = /^[0-9a-fA-F]{64}$/;
28
+ const LINE = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${NAME}[ \\t]*=[ \\t]*(.*?)[ \\t]*$`, 'm');
29
+ const COMMENT = '# Encrypts the API credentials saved in the admin. Back this up with your database: if it is lost they cannot be recovered.';
30
+
31
+ const unquote = value => value.replace(/^(['"])(.*)\1$/, '$2');
32
+
33
+ const readText = async path => {
34
+ try {
35
+ return await readFile(path, 'utf8');
36
+ } catch(error) {
37
+ if(error.code === 'ENOENT') return null;
38
+ throw error;
39
+ }
40
+ };
41
+
42
+ /* How many settings are stored encrypted. A site that has not created its tables yet has none. */
43
+ const storedSecrets = async () => {
44
+ try {
45
+ const [{ total }] = await db.select({ total: sql`count(*)::int` }).from(setting)
46
+ .where(and(eq(setting.type, 'secret'), isNotNull(setting.value), ne(setting.value, '')));
47
+ return [null, total];
48
+ } catch(error) {
49
+ if((error?.code || error?.cause?.code) === '42P01') return [null, 0];
50
+ return [{ code: 500, msg: 'Could not check whether this site already has encrypted settings, so no encryption key was created' }, null];
51
+ }
52
+ };
53
+
54
+ /* Puts the key in `.env`: replaces an empty `SETTINGS_ENCRYPTION_KEY=` line, or adds one. */
55
+ const saveKey = async (path, key, text) => {
56
+ const line = `${NAME}=${key}`;
57
+ let next;
58
+ if(text === null) next = `${COMMENT}\n${line}\n`;
59
+ else if(LINE.test(text)) next = text.replace(LINE, line);
60
+ else next = `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}\n${COMMENT}\n${line}\n`;
61
+ await writeFile(path, next, { mode: 0o600 });
62
+ await chmod(path, 0o600).catch(() => {});
63
+ };
64
+
65
+ /* A .gitignore that exists but does not name `.env` would let the key be committed. */
66
+ const keepOutOfGit = async dir => {
67
+ const path = join(dir, '.gitignore');
68
+ try {
69
+ const text = await readText(path);
70
+ if(text === null || /^\/?\.env\s*$/m.test(text)) return;
71
+ await writeFile(path, `${text}${text.endsWith('\n') ? '' : '\n'}.env\n`);
72
+ } catch {
73
+ /* Not being able to tidy .gitignore is not worth refusing a key over. */
74
+ }
75
+ };
76
+
77
+ const resolve = async ({ cwd }) => {
78
+ const fromEnvironment = process.env[NAME];
79
+ if(fromEnvironment){
80
+ return VALID.test(fromEnvironment)
81
+ ? [null, { source: 'environment' }]
82
+ : [{ code: 500, msg: `${NAME} must be a 64-character hex string (32 bytes). Remove it to have one generated, or replace it with the key this site was created with` }, null];
83
+ }
84
+
85
+ const path = join(cwd, '.env');
86
+ let text;
87
+ try {
88
+ text = await readText(path);
89
+ } catch(error) {
90
+ return [{ code: 500, msg: `Could not read ${path} (${error.code ?? error.message}), so no encryption key was created` }, null];
91
+ }
92
+
93
+ const inFile = text === null ? '' : unquote(LINE.exec(text)?.[1] ?? '');
94
+ if(inFile){
95
+ if(!VALID.test(inFile)) return [{ code: 500, msg: `${NAME} in ${path} must be a 64-character hex string (32 bytes)` }, null];
96
+ process.env[NAME] = inFile;
97
+ return [null, { source: 'file', path }];
98
+ }
99
+
100
+ const [countError, count] = await storedSecrets();
101
+ if(countError) return [countError, null];
102
+ if(count > 0){
103
+ return [{ code: 500, msg: `This site has ${count} encrypted setting${count === 1 ? '' : 's'} but ${NAME} is not set. Restore the key it was created with (your .env backup, or the host's environment). A new key would make them unreadable` }, null];
104
+ }
105
+
106
+ const key = crypto.randomBytes(32).toString('hex');
107
+ try {
108
+ await saveKey(path, key, text);
109
+ } catch(error) {
110
+ return [{ code: 500, msg: `Could not save a new ${NAME} to ${path} (${error.code ?? error.message}). Set ${NAME} in the server's environment instead` }, null];
111
+ }
112
+ await keepOutOfGit(cwd);
113
+ process.env[NAME] = key;
114
+ console.warn(`[kempo] ${NAME} was not set, so one was generated and saved to ${path}. Back it up with your database: if it is lost, stored credentials (Stripe keys and the like) cannot be recovered and must be entered again.`);
115
+ return [null, { source: 'generated', path }];
116
+ };
117
+
118
+ /* One answer per process: concurrent callers share the work, and a failure can be tried again. */
119
+ let pending = null;
120
+
121
+ export const ensureEncryptionKey = ({ cwd = process.cwd() } = {}) => {
122
+ pending ??= resolve({ cwd }).then(result => {
123
+ if(result[0]) pending = null;
124
+ return result;
125
+ });
126
+ return pending;
127
+ };
128
+
129
+ /* For tests: forget the answer so the next call decides again. */
130
+ export const resetEncryptionKey = () => {
131
+ pending = null;
132
+ };
@@ -37,5 +37,9 @@ export const decryptSecret = encoded => {
37
37
  const ciphertext = data.subarray(28);
38
38
  const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
39
39
  decipher.setAuthTag(authTag);
40
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
40
+ try {
41
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
42
+ } catch {
43
+ throw new Error('A stored secret could not be decrypted: SETTINGS_ENCRYPTION_KEY is not the key it was saved with. Restore the original key, or enter the credential again');
44
+ }
41
45
  };
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M560-320h80v-80h80v-80h-80v-80h-80v80h-80v80h80v80ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M880-720v480q0 33-23.5 56.5T800-160H160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720ZM160-640h640v-80H160v80Zm0 160v240h640v-240H160Zm0 240v-480 480Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M480-120v-80h280v-560H480v-80h280q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H480Zm-80-160-55-58 102-102H120v-80h327L345-622l55-58 200 200-200 200Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M560-440q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35ZM280-320q-33 0-56.5-23.5T200-400v-320q0-33 23.5-56.5T280-800h560q33 0 56.5 23.5T920-720v320q0 33-23.5 56.5T840-320H280Zm80-80h400q0-33 23.5-56.5T840-480v-160q-33 0-56.5-23.5T760-720H360q0 33-23.5 56.5T280-640v160q33 0 56.5 23.5T360-400Zm440 240H120q-33 0-56.5-23.5T40-240v-440h80v440h680v80ZM280-400v-320 320Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M373-253q-93-93-93-227t93-227q93-93 227-93t227 93q93 93 93 227t-93 227q-93 93-227 93t-227-93Zm-93 83q-106-28-173-114T40-480q0-110 67-196t173-114v84q-72 25-116 87t-44 139q0 77 44 139t116 87v84Zm320-310Zm170 170q70-70 70-170t-70-170q-70-70-170-70t-170 70q-70 70-70 170t70 170q70 70 170 70t170-70Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v80h-80v-80H200v560h560v-80h80v80q0 33-23.5 56.5T760-120H200Zm320-160q-33 0-56.5-23.5T440-360v-240q0-33 23.5-56.5T520-680h280q33 0 56.5 23.5T880-600v240q0 33-23.5 56.5T800-280H520Zm280-80v-240H520v240h280Zm-160-60q25 0 42.5-17.5T700-480q0-25-17.5-42.5T640-540q-25 0-42.5 17.5T580-480q0 25 17.5 42.5T640-420Z"/></svg>