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
@@ -14,6 +14,11 @@ import createTablesFromSchema from './createTablesFromSchema.js';
14
14
  import readKempoConfig from './readKempoConfig.js';
15
15
  import { invalidateScopeCache } from './scopeCache.js';
16
16
  import { getUnmetDependencies } from './dependencies.js';
17
+ import registerJobs from '../scheduler/registerJobs.js';
18
+ import { normalizeJobs } from '../scheduler/helpers.js';
19
+ import { normalizeAliases } from './aliases.js';
20
+ import { normalizeScopes, scopeNames } from './scopes.js';
21
+ import checkClaims from './checkClaims.js';
17
22
 
18
23
  export default async ({ name }) => {
19
24
  if(!name){
@@ -48,6 +53,22 @@ export default async ({ name }) => {
48
53
  return [{ code: 409, msg: `Requires ${unmet.join(', ')} to be installed and enabled first` }, null];
49
54
  }
50
55
 
56
+ const [jobsError] = normalizeJobs(kempoConfig.jobs);
57
+ if(jobsError) return [jobsError, null];
58
+
59
+ const [scopesError] = normalizeScopes(kempoConfig);
60
+ if(scopesError) return [scopesError, null];
61
+
62
+ const [aliasesError] = normalizeAliases(kempoConfig.aliases, scopeNames(kempoConfig));
63
+ if(aliasesError) return [aliasesError, null];
64
+
65
+ /*
66
+ An address can belong to one extension only. Installing a second one that claims it would leave
67
+ it with no public pages and no error, so it is refused here and the first has to be disabled.
68
+ */
69
+ const [claimsError] = await checkClaims({ names: [name], verb: 'install', kempo: { [name]: kempoConfig } });
70
+ if(claimsError) return [claimsError, null];
71
+
51
72
  /*
52
73
  Create tables from declarative schema
53
74
  */
@@ -131,6 +152,17 @@ export default async ({ name }) => {
131
152
  }
132
153
  }
133
154
 
155
+ /*
156
+ Register declarative scheduled jobs. Like a widget, a job that cannot be registered (a site that has
157
+ not applied the scheduledJob table yet) is reported and skipped rather than failing the install.
158
+ */
159
+ if((kempoConfig.jobs || []).length){
160
+ const [registerError] = await registerJobs({ owner: name, jobs: kempoConfig.jobs });
161
+ if(registerError){
162
+ console.warn(`[${name}] scheduled jobs were not registered: ${registerError.msg}`);
163
+ }
164
+ }
165
+
134
166
  const now = new Date();
135
167
  const entry = {
136
168
  name,
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
6
6
  import installExtension from './installExtension.js';
7
7
  import installPackage from './installPackage.js';
8
8
  import enableExtension from './enableExtension.js';
9
- import { NAME_PATTERN } from './catalogClient.js';
9
+ import { NAME_PATTERN } from './marketplaceClient.js';
10
10
 
11
11
  const isInstalled = async name => {
12
12
  const [row] = await db.select({ name: extension.name }).from(extension).where(eq(extension.name, name)).limit(1);
@@ -1,6 +1,6 @@
1
1
  import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
- import { NAME_PATTERN } from './catalogClient.js';
3
+ import { NAME_PATTERN } from './marketplaceClient.js';
4
4
 
5
5
  const run = promisify(execFile);
6
6
 
@@ -181,12 +181,15 @@ export const createCatalogClient = ({ baseUrl = DEFAULT_CATALOG_URL, fetchImpl =
181
181
  };
182
182
 
183
183
  return {
184
- list: ({ search, sort, limit, offset } = {}) => {
184
+ /* `exclude` is a list of extension names to leave out, which is how a site hides what it has installed. */
185
+ list: ({ search, sort, limit, offset, exclude } = {}) => {
185
186
  const query = new URLSearchParams();
186
187
  if(search) query.set('search', String(search).slice(0, 100));
187
188
  if(sort) query.set('sort', String(sort));
188
189
  if(limit) query.set('limit', String(limit));
189
190
  if(offset) query.set('offset', String(offset));
191
+ const excluded = (Array.isArray(exclude) ? exclude : []).filter(name => NAME_PATTERN.test(name || '')).slice(0, 500);
192
+ if(excluded.length) query.set('exclude', excluded.join(','));
190
193
  return read(`/extensions${query.size ? `?${query}` : ''}`, normalizePage);
191
194
  },
192
195
 
@@ -2,8 +2,8 @@ import { join } from 'path';
2
2
  import { readFile } from 'fs/promises';
3
3
 
4
4
  /*
5
- What an installed extension declares to kempo: its `public-scope`, `docs`, `git`, `schema`, `hooks`,
6
- `permissions`, `settings`, `groups`, `dependencies` and `widgets`.
5
+ What an installed extension declares to kempo: its `public-scope` and `public-scopes`, `docs`, `git`, `schema`, `hooks`,
6
+ `permissions`, `settings`, `groups`, `dependencies`, `widgets`, `jobs` and `aliases`.
7
7
 
8
8
  It lives in the extension's kempo-config.json, which is the one place it is written. package.json's own
9
9
  `kempo` field is still read, as the fallback for an older extension that put everything there, and
@@ -3,7 +3,20 @@ import { extension } from '../../db/schema.js';
3
3
 
4
4
  let cache = null;
5
5
 
6
- export const invalidateScopeCache = () => { cache = null; };
6
+ const listeners = new Set();
7
+
8
+ /*
9
+ For what has to follow the set of enabled extensions, such as menus holding an extension's items.
10
+ Called after every install, enable, disable and uninstall; a listener's failure never reaches them.
11
+ */
12
+ export const onExtensionsChanged = listener => listeners.add(listener);
13
+
14
+ export const invalidateScopeCache = () => {
15
+ cache = null;
16
+ for(const listener of listeners){
17
+ Promise.resolve().then(listener).catch(error => console.error('[kempo] An extension change listener failed:', error.message));
18
+ }
19
+ };
7
20
 
8
21
  /* One read of the extension table serves both questions, until an install/enable/disable/uninstall. */
9
22
  const load = async () => {
@@ -0,0 +1,72 @@
1
+ /*
2
+ The addresses an extension answers on, read from its kempo-config.json.
3
+
4
+ An extension serves a folder of its package at an address under its scope: with the scope `products`,
5
+ `public/index.page.html` is `/products/` and `public/api/products/GET.js` is `/products/api/products`.
6
+ It can claim more than one scope, each serving its own folder:
7
+
8
+ "public-scope": "products", // shorthand for "products": "public"
9
+ "public-scopes": { "categories": "public-categories" } // /categories/ serves public-categories/
10
+
11
+ `aliases` give pages under a scope a second, shorter address (see aliases.js). Every scope and every
12
+ alias address is a *claim*: no two enabled extensions may claim the same address, or one inside the
13
+ other, because the router can only give an address to one of them (see claims.js).
14
+ */
15
+
16
+ const NAME = /^[a-z0-9][a-z0-9_-]*$/;
17
+
18
+ /* What kempo itself serves at the top level of the site. */
19
+ export const RESERVED_SCOPES = ['admin', 'api', 'kempo', 'kempo-css', 'kempo-ui', 'monaco-editor'];
20
+
21
+ const cleanDir = dir => String(dir).replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
22
+
23
+ const badDir = dir => !dir || dir.startsWith('/') || /^[a-z]:/i.test(dir) || dir.split('/').some(part => part === '..' || part === '');
24
+
25
+ /*
26
+ The scopes an extension declares, each with the folder of its package it serves: [error, [{ scope, dir }]].
27
+ An extension with no scope gets an empty list. A bad name, a reserved one, a folder that leaves the
28
+ package, or the same scope pointed at two folders is an error.
29
+ */
30
+ export const normalizeScopes = kempo => {
31
+ const found = new Map();
32
+
33
+ const add = (scope, folder) => {
34
+ if(typeof scope !== 'string' || !NAME.test(scope)){
35
+ return { code: 400, msg: `Scope "${scope}" must be lowercase letters, numbers, dashes or underscores, such as "products"` };
36
+ }
37
+ if(RESERVED_SCOPES.includes(scope)){
38
+ return { code: 400, msg: `Scope "${scope}" is used by kempo itself` };
39
+ }
40
+ const dir = cleanDir(folder);
41
+ if(typeof folder !== 'string' || badDir(dir)){
42
+ return { code: 400, msg: `The folder for scope "${scope}" must be a folder inside the package, such as "public"` };
43
+ }
44
+ if(found.has(scope) && found.get(scope) !== dir){
45
+ return { code: 400, msg: `Scope "${scope}" is given two different folders` };
46
+ }
47
+ found.set(scope, dir);
48
+ return null;
49
+ };
50
+
51
+ const single = kempo?.['public-scope'];
52
+ if(single !== undefined && single !== null && single !== ''){
53
+ const error = add(single, 'public');
54
+ if(error) return [error, null];
55
+ }
56
+
57
+ const several = kempo?.['public-scopes'];
58
+ if(several !== undefined && several !== null){
59
+ if(typeof several !== 'object' || Array.isArray(several)){
60
+ return [{ code: 400, msg: 'public-scopes must be an object of scope to folder' }, null];
61
+ }
62
+ for(const [scope, folder] of Object.entries(several)){
63
+ const error = add(scope, folder);
64
+ if(error) return [error, null];
65
+ }
66
+ }
67
+
68
+ return [null, [...found].map(([scope, dir]) => ({ scope, dir }))];
69
+ };
70
+
71
+ /* Just the scope names, for what only needs to know them (an alias must point inside one). */
72
+ export const scopeNames = kempo => (normalizeScopes(kempo)[1] ?? []).map(({ scope }) => scope);
@@ -1,5 +1,5 @@
1
1
  import db from '../../db/index.js';
2
- import { extension, hook, permission, setting, group, groupPermission, userGroup, dashboardWidget } from '../../db/schema.js';
2
+ import { extension, hook, permission, setting, group, groupPermission, userGroup, dashboardWidget, scheduledJob } from '../../db/schema.js';
3
3
  import { eq, inArray, like } from 'drizzle-orm';
4
4
  import { join } from 'path';
5
5
  import { pathToFileURL } from 'url';
@@ -105,6 +105,7 @@ const uninstallOne = async ({ name, purgeData = false }) => {
105
105
  try {
106
106
  await db.delete(hook).where(eq(hook.owner, name));
107
107
  await db.delete(dashboardWidget).where(eq(dashboardWidget.owner, name));
108
+ await db.delete(scheduledJob).where(eq(scheduledJob.owner, name)).catch(() => {});
108
109
  await db.delete(extension).where(eq(extension.name, name));
109
110
  } catch(error) {
110
111
  return [{ code: 500, msg: 'Failed to remove extension' }, null];
@@ -9,10 +9,14 @@ import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
9
9
  import createHook from '../hooks/createHook.js';
10
10
  import registerDashboardWidget from '../dashboard/registerDashboardWidget.js';
11
11
  import unregisterDashboardWidget from '../dashboard/unregisterDashboardWidget.js';
12
+ import registerJobs from '../scheduler/registerJobs.js';
12
13
  import setSetting from '../settings/setSetting.js';
13
14
  import declaredValue from '../settings/declaredValue.js';
14
15
  import createTablesFromSchema from './createTablesFromSchema.js';
15
16
  import readKempoConfig from './readKempoConfig.js';
17
+ import { normalizeAliases } from './aliases.js';
18
+ import { normalizeScopes, scopeNames } from './scopes.js';
19
+ import checkClaims from './checkClaims.js';
16
20
 
17
21
  const requireFromProject = createRequire(join(process.cwd(), 'package.json'));
18
22
 
@@ -56,6 +60,27 @@ export default async ({ name, version = 'latest' }) => {
56
60
  return [{ code: 404, msg: `Updated package "${name}" not found` }, null];
57
61
  }
58
62
 
63
+ /*
64
+ The new version may claim addresses the old one did not. If it is on and another extension has one
65
+ of them, put the old version back before anything else changes: leaving the new one would break
66
+ one of the two, silently.
67
+ */
68
+ const [scopesError] = normalizeScopes(newKempo);
69
+ const [aliasesError] = scopesError ? [null] : normalizeAliases(newKempo.aliases, scopeNames(newKempo));
70
+ const [claimsError] = scopesError || aliasesError || !oldRecord.enabled
71
+ ? [null]
72
+ : await checkClaims({ names: [name], verb: 'update', kempo: { [name]: newKempo } });
73
+ const refusal = scopesError || aliasesError || claimsError;
74
+ if(refusal){
75
+ let msg = refusal.msg;
76
+ try {
77
+ if(oldVersion) execSync(`npm install ${name}@${oldVersion}`, { cwd: process.cwd(), stdio: 'pipe' });
78
+ } catch {
79
+ msg += ` The previous version could not be put back automatically: run npm install ${name}@${oldVersion}.`;
80
+ }
81
+ return [{ ...refusal, msg }, null];
82
+ }
83
+
59
84
  /*
60
85
  Create any new tables from schema
61
86
  */
@@ -161,6 +186,16 @@ export default async ({ name, version = 'latest' }) => {
161
186
  }
162
187
  }
163
188
 
189
+ /*
190
+ Sync scheduled jobs: the schedule becomes exactly what is declared now
191
+ */
192
+ if((newKempo.jobs || []).length || (oldKempo.jobs || []).length){
193
+ const [jobsError] = await registerJobs({ owner: name, jobs: newKempo.jobs || [] });
194
+ if(jobsError){
195
+ console.warn(`[${name}] scheduled jobs were not registered: ${jobsError.msg}`);
196
+ }
197
+ }
198
+
164
199
  const now = new Date();
165
200
  try {
166
201
  await db.update(extension).set({
@@ -2,25 +2,10 @@ import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import crypto from 'crypto';
5
- import { GLOBAL_FILE, parseEntries } from './helpers.js';
5
+ import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
6
6
  import triggerHook from '../hooks/triggerHook.js';
7
7
 
8
- const serializeEntries = entries => entries.map(e => {
9
- const attrs = [
10
- `id="${e.id}"`,
11
- `name="${e.name}"`,
12
- `owner="${e.owner}"`,
13
- `author="${e.author}"`,
14
- `created="${e.createdAt}"`,
15
- `updated="${e.updatedAt}"`,
16
- `locked="${e.locked}"`,
17
- `location="${e.location}"`,
18
- `priority="${e.priority}"`
19
- ].join(' ');
20
- return `<content ${attrs}>\n${e.markup}\n</content>`;
21
- }).join('\n');
22
-
23
- export default async ({ rootDir, name, location, priority, author, owner = 'custom' }) => {
8
+ export default async ({ rootDir, name, location, priority, author, owner = 'custom', markup = '' }) => {
24
9
  if(!rootDir){
25
10
  return [{ code: 400, msg: 'Root directory is required' }, null];
26
11
  }
@@ -45,11 +30,16 @@ export default async ({ rootDir, name, location, priority, author, owner = 'cust
45
30
  owner,
46
31
  author: author || '',
47
32
  locked: false,
33
+ menu: '',
34
+ menuLocations: null,
35
+ menuCopyOf: '',
36
+ navItem: '',
37
+ extra: {},
48
38
  location,
49
39
  priority: parseInt(priority || '0', 10),
50
40
  createdAt: now,
51
41
  updatedAt: now,
52
- markup: ''
42
+ markup
53
43
  };
54
44
 
55
45
  entries.push(entry);
@@ -1,24 +1,9 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { GLOBAL_FILE, parseEntries } from './helpers.js';
4
+ import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
5
5
  import triggerHook from '../hooks/triggerHook.js';
6
6
 
7
- const serializeEntries = entries => entries.map(e => {
8
- const attrs = [
9
- `id="${e.id}"`,
10
- `name="${e.name}"`,
11
- `owner="${e.owner}"`,
12
- `author="${e.author}"`,
13
- `created="${e.createdAt}"`,
14
- `updated="${e.updatedAt}"`,
15
- `locked="${e.locked}"`,
16
- `location="${e.location}"`,
17
- `priority="${e.priority}"`
18
- ].join(' ');
19
- return `<content ${attrs}>\n${e.markup}\n</content>`;
20
- }).join('\n');
21
-
22
7
  export default async ({ rootDir, ids }) => {
23
8
  if(!rootDir){
24
9
  return [{ code: 400, msg: 'Root directory is required' }, null];
@@ -1,22 +1,7 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries } from './helpers.js';
5
-
6
- const serializeEntries = entries => entries.map(e => {
7
- const attrs = [
8
- `id="${e.id}"`,
9
- `name="${e.name}"`,
10
- `owner="${e.owner}"`,
11
- `author="${e.author}"`,
12
- `created="${e.createdAt}"`,
13
- `updated="${e.updatedAt}"`,
14
- `locked="${e.locked}"`,
15
- `location="${e.location}"`,
16
- `priority="${e.priority}"`
17
- ].join(' ');
18
- return `<content ${attrs}>\n${e.markup}\n</content>`;
19
- }).join('\n');
4
+ import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries } from './helpers.js';
20
5
 
21
6
  export default async ({ rootDir, id }) => {
22
7
  if(!rootDir){
@@ -1,22 +1,7 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries } from './helpers.js';
5
-
6
- const serializeEntries = entries => entries.map(e => {
7
- const attrs = [
8
- `id="${e.id}"`,
9
- `name="${e.name}"`,
10
- `owner="${e.owner}"`,
11
- `author="${e.author}"`,
12
- `created="${e.createdAt}"`,
13
- `updated="${e.updatedAt}"`,
14
- `locked="${e.locked}"`,
15
- `location="${e.location}"`,
16
- `priority="${e.priority}"`
17
- ].join(' ');
18
- return `<content ${attrs}>\n${e.markup}\n</content>`;
19
- }).join('\n');
4
+ import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries } from './helpers.js';
20
5
 
21
6
  export default async ({ rootDir, id }) => {
22
7
  if(!rootDir){
@@ -1,10 +1,19 @@
1
- import { readFile } from 'fs/promises';
2
- import { join } from 'path';
3
- import { existsSync } from 'fs';
4
-
5
1
  const GLOBAL_FILE = 'kempo-global.global.html';
6
2
  const GLOBAL_DISABLED_FILE = 'kempo-global.global-disabled.html';
7
3
 
4
+ /*
5
+ A quote or angle bracket inside an attribute would end the tag early, so they are written as
6
+ entities and read back.
7
+ */
8
+ const escapeAttr = value => String(value ?? '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
9
+ const unescapeAttr = value => value.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
10
+
11
+ const KNOWN_ATTRIBUTES = ['id', 'name', 'owner', 'author', 'locked', 'location', 'priority', 'created', 'updated', 'menu', 'menu-locations', 'menu-copy', 'menu-nav-item'];
12
+
13
+ /*
14
+ Attributes this code does not know about are kept in `extra` and written back unchanged, so a
15
+ marker added by hand or by a newer version survives a save instead of being silently dropped.
16
+ */
8
17
  const parseEntries = raw => {
9
18
  const entries = [];
10
19
  const regex = /<content\s([^>]*)>([\s\S]*?)<\/content>/g;
@@ -13,21 +22,46 @@ const parseEntries = raw => {
13
22
  const attrs = {};
14
23
  const attrRegex = /(\w[\w-]*)="([^"]*)"/g;
15
24
  let a;
16
- while((a = attrRegex.exec(match[1])) !== null) attrs[a[1]] = a[2];
25
+ while((a = attrRegex.exec(match[1])) !== null) attrs[a[1]] = unescapeAttr(a[2]);
17
26
  entries.push({
18
27
  id: attrs.id || '',
19
28
  name: attrs.name || '',
20
29
  owner: attrs.owner || 'custom',
21
30
  author: attrs.author || '',
22
31
  locked: attrs.locked === 'true',
32
+ menu: attrs.menu || '',
33
+ menuLocations: attrs['menu-locations'] === undefined ? null : attrs['menu-locations'].split(',').map(l => l.trim()).filter(Boolean),
34
+ menuCopyOf: attrs['menu-copy'] || '',
35
+ navItem: attrs['menu-nav-item'] || '',
23
36
  location: attrs.location || 'default',
24
37
  priority: parseInt(attrs.priority || '0', 10),
25
38
  createdAt: attrs.created || '',
26
39
  updatedAt: attrs.updated || '',
40
+ extra: Object.fromEntries(Object.entries(attrs).filter(([key]) => !KNOWN_ATTRIBUTES.includes(key))),
27
41
  markup: match[2].trim()
28
42
  });
29
43
  }
30
44
  return entries;
31
45
  };
32
46
 
33
- export { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries };
47
+ const serializeEntries = entries => entries.map(e => {
48
+ const attrs = [
49
+ `id="${e.id}"`,
50
+ `name="${escapeAttr(e.name)}"`,
51
+ `owner="${e.owner}"`,
52
+ `author="${escapeAttr(e.author)}"`,
53
+ `created="${e.createdAt}"`,
54
+ `updated="${e.updatedAt}"`,
55
+ `locked="${e.locked}"`,
56
+ `location="${e.location}"`,
57
+ `priority="${e.priority}"`,
58
+ ...(e.menu ? [`menu="${e.menu}"`] : []),
59
+ ...(e.menuLocations ? [`menu-locations="${e.menuLocations.join(',')}"`] : []),
60
+ ...(e.menuCopyOf ? [`menu-copy="${e.menuCopyOf}"`] : []),
61
+ ...(e.navItem ? [`menu-nav-item="${e.navItem}"`] : []),
62
+ ...Object.entries(e.extra || {}).map(([key, value]) => `${key}="${escapeAttr(value)}"`)
63
+ ].join(' ');
64
+ return `<content ${attrs}>\n${e.markup}\n</content>`;
65
+ }).join('\n');
66
+
67
+ export { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries };
@@ -3,7 +3,7 @@ import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries } from './helpers.js';
5
5
 
6
- export default async ({ rootDir }) => {
6
+ export default async ({ rootDir, includeMenu = true }) => {
7
7
  if(!rootDir){
8
8
  return [{ code: 400, msg: 'Root directory is required' }, null];
9
9
  }
@@ -19,6 +19,6 @@ export default async ({ rootDir }) => {
19
19
  ? parseEntries(await readFile(disabledPath, 'utf-8')).map(e => ({ ...e, disabled: true }))
20
20
  : [];
21
21
 
22
- const entries = [...activeEntries, ...disabledEntries];
22
+ const entries = [...activeEntries, ...disabledEntries].filter(e => includeMenu || !e.menu);
23
23
  return [null, { entries, total: entries.length }];
24
24
  };
@@ -1,22 +1,7 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { GLOBAL_FILE, parseEntries } from './helpers.js';
5
-
6
- const serializeEntries = entries => entries.map(e => {
7
- const attrs = [
8
- `id="${e.id}"`,
9
- `name="${e.name}"`,
10
- `owner="${e.owner}"`,
11
- `author="${e.author}"`,
12
- `created="${e.createdAt}"`,
13
- `updated="${e.updatedAt}"`,
14
- `locked="${e.locked}"`,
15
- `location="${e.location}"`,
16
- `priority="${e.priority}"`
17
- ].join(' ');
18
- return `<content ${attrs}>\n${e.markup}\n</content>`;
19
- }).join('\n');
4
+ import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
20
5
 
21
6
  export default async ({ rootDir, id, locked }) => {
22
7
  if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
@@ -1,24 +1,9 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { GLOBAL_FILE, parseEntries } from './helpers.js';
4
+ import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
5
5
  import triggerHook from '../hooks/triggerHook.js';
6
6
 
7
- const serializeEntries = entries => entries.map(e => {
8
- const attrs = [
9
- `id="${e.id}"`,
10
- `name="${e.name}"`,
11
- `owner="${e.owner}"`,
12
- `author="${e.author}"`,
13
- `created="${e.createdAt}"`,
14
- `updated="${e.updatedAt}"`,
15
- `locked="${e.locked}"`,
16
- `location="${e.location}"`,
17
- `priority="${e.priority}"`
18
- ].join(' ');
19
- return `<content ${attrs}>\n${e.markup}\n</content>`;
20
- }).join('\n');
21
-
22
7
  export default async ({ rootDir, id, name, location, priority, markup }) => {
23
8
  if(!rootDir){
24
9
  return [{ code: 400, msg: 'Root directory is required' }, null];
@@ -0,0 +1,58 @@
1
+ import { readStores, writeStores, menuHome, isMenuDefinition } from './store.js';
2
+ import { parseLink } from './markup.js';
3
+ import saveMenu from './saveMenu.js';
4
+ import triggerHook from '../hooks/triggerHook.js';
5
+
6
+ /*
7
+ Moves an existing single link into a menu so it is managed on the Menus page. Only a single link
8
+ qualifies: anything else would lose what it is made of when the page rewrites it.
9
+
10
+ Without a `menuId` it goes into the menu already shown in the link's location; if there is none, a
11
+ menu named `menuName` is made and shown there, so the link stays exactly where it was on the site.
12
+ */
13
+ export default async ({ rootDir, id, menuId, menuName, extensionDirs }) => {
14
+ if(!rootDir){
15
+ return [{ code: 400, msg: 'Root directory is required' }, null];
16
+ }
17
+ if(!id){
18
+ return [{ code: 400, msg: 'Global content ID is required' }, null];
19
+ }
20
+
21
+ let stores = await readStores(rootDir);
22
+ const entry = stores.active.find(e => e.id === id && !e.menu);
23
+ if(!entry){
24
+ return [{ code: 404, msg: 'Global content not found' }, null];
25
+ }
26
+ if(entry.owner !== 'custom'){
27
+ return [{ code: 403, msg: 'This belongs to an extension' }, null];
28
+ }
29
+ if(!parseLink(entry.markup)){
30
+ return [{ code: 400, msg: 'Only a single link can be managed as a menu item' }, null];
31
+ }
32
+
33
+ let target = menuId
34
+ ? stores.active.find(e => e.id === menuId && isMenuDefinition(e))
35
+ : stores.active.find(e => isMenuDefinition(e) && e.menuLocations.includes(entry.location));
36
+ if(menuId && !target){
37
+ return [{ code: 404, msg: 'Menu not found' }, null];
38
+ }
39
+
40
+ if(!target){
41
+ const [error, created] = await saveMenu({ rootDir, name: menuName || entry.location, extensionDirs });
42
+ if(error){
43
+ return [error, null];
44
+ }
45
+ stores = await readStores(rootDir);
46
+ target = stores.active.find(e => e.id === created.id);
47
+ target.menuLocations = [entry.location];
48
+ }
49
+
50
+ const live = stores.active.find(e => e.id === id);
51
+ live.menu = target.id;
52
+ live.location = menuHome(target.id);
53
+ live.updatedAt = new Date().toISOString();
54
+ await writeStores(rootDir, stores, extensionDirs);
55
+ await triggerHook('globalContent:updated', { id, updatedAt: live.updatedAt });
56
+
57
+ return [null, { id, menuId: target.id }];
58
+ };
@@ -0,0 +1,47 @@
1
+ import { readStores, writeStores, isMenuDefinition } from './store.js';
2
+ import findMenuLocations from './findMenuLocations.js';
3
+ import triggerHook from '../hooks/triggerHook.js';
4
+
5
+ /*
6
+ Chooses the menu shown in a location, or none when `menuId` is empty. A location shows one menu, so
7
+ choosing one takes the location from whichever menu had it; a menu can be chosen for several.
8
+ Only a place the theme marks as a menu can be newly assigned, so a typo cannot silently show nothing.
9
+ */
10
+ export default async ({ rootDir, location, menuId, extensionDirs }) => {
11
+ if(!rootDir){
12
+ return [{ code: 400, msg: 'Root directory is required' }, null];
13
+ }
14
+ if(!location){
15
+ return [{ code: 400, msg: 'Location is required' }, null];
16
+ }
17
+
18
+ const stores = await readStores(rootDir);
19
+ const definitions = stores.active.filter(isMenuDefinition);
20
+ const target = menuId ? definitions.find(d => d.id === menuId) : null;
21
+ if(menuId && !target){
22
+ return [{ code: 404, msg: 'Menu not found' }, null];
23
+ }
24
+
25
+ if(target){
26
+ const [, declared] = await findMenuLocations({ rootDir });
27
+ if(!target.menuLocations.includes(location) && !declared.some(l => l.location === location)){
28
+ return [{ code: 400, msg: 'That place is not a menu in the theme' }, null];
29
+ }
30
+ }
31
+
32
+ const now = new Date().toISOString();
33
+ for(const definition of definitions){
34
+ if(!definition.menuLocations.includes(location)) continue;
35
+ definition.menuLocations = definition.menuLocations.filter(l => l !== location);
36
+ definition.updatedAt = now;
37
+ }
38
+ if(target){
39
+ target.menuLocations = [...target.menuLocations, location];
40
+ target.updatedAt = now;
41
+ }
42
+
43
+ await writeStores(rootDir, stores, extensionDirs);
44
+ await triggerHook('globalContent:updated', { id: target?.id ?? '', updatedAt: now });
45
+
46
+ return [null, { location, menuId: target?.id ?? null }];
47
+ };