domma-cms 0.55.1 → 0.69.3
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/CLAUDE.md +194 -3
- package/README.md +18 -14
- package/admin/css/admin.css +1 -1
- package/admin/css/contacts.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +211 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +28 -24
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +6 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +27 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/server.json +3 -1
- package/package.json +14 -10
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4473 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1022 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -0
- package/plugins/blog/admin/templates/blog.html +35 -41
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +63 -40
- package/plugins/blog/admin/templates/settings.html +3 -9
- package/plugins/blog/admin/views/blog.js +288 -159
- package/plugins/blog/admin/views/categories.js +212 -206
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +306 -39
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -84
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +16 -13
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +259 -26
- package/plugins/blog/plugin.json +21 -73
- package/plugins/blog/plugin.public.js +217 -0
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +34 -0
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +77 -4
- package/scripts/copy-domma.js +48 -0
- package/scripts/run-tests.mjs +92 -0
- package/scripts/seed.js +1996 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/contacts.js +506 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/dashboard.js +8 -2
- package/server/routes/api/forms.js +42 -3
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +198 -9
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/api/todo.js +178 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +135 -1
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/collections.js +13 -3
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +124 -10
- package/server/services/permissionRegistry.js +74 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +340 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +224 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +580 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/analytics/admin/templates/analytics.html +0 -61
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -57
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -126
- package/plugins/contacts/admin/views/contacts.js +0 -710
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -71
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/contacts/plugin.js +0 -291
- package/plugins/contacts/plugin.json +0 -33
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/templates/editor.html +0 -129
- package/plugins/invoice/admin/templates/index.html +0 -43
- package/plugins/invoice/admin/templates/issuers.html +0 -5
- package/plugins/invoice/admin/templates/receivers.html +0 -5
- package/plugins/invoice/admin/views/editor.js +0 -267
- package/plugins/invoice/admin/views/index.js +0 -155
- package/plugins/invoice/admin/views/issuers.js +0 -23
- package/plugins/invoice/admin/views/party-view.js +0 -148
- package/plugins/invoice/admin/views/receivers.js +0 -22
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
- package/plugins/invoice/collections/invoices/schema.json +0 -27
- package/plugins/invoice/config.js +0 -16
- package/plugins/invoice/plugin.js +0 -283
- package/plugins/invoice/plugin.json +0 -85
- package/plugins/invoice/templates/invoice-print.html +0 -213
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/notes/plugin.json +0 -33
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/data/todos.json +0 -1
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entitlement verification - may this plugin run here?
|
|
3
|
+
*
|
|
4
|
+
* Only asked of a plugin declaring `closedSource: true`. A plugin the user wrote
|
|
5
|
+
* has no entitlement, needs none, and must never be asked for one: the gate keys
|
|
6
|
+
* on a flag WE set on OUR paid plugins, so the free builder is untouched by all
|
|
7
|
+
* of this.
|
|
8
|
+
*
|
|
9
|
+
* The two commercial models expire in completely different ways, and getting it
|
|
10
|
+
* backwards is the mistake with real consequences:
|
|
11
|
+
*
|
|
12
|
+
* rental a managed site pays monthly. Past its term plus grace, it stops.
|
|
13
|
+
* perpetual bought outright. It NEVER stops. What lapses is support and
|
|
14
|
+
* updates - so a customer who paid a large sum once and declined
|
|
15
|
+
* the renewal keeps what they bought, forever, offline.
|
|
16
|
+
*
|
|
17
|
+
* A wildcard entitlement (`slug: "*"`) satisfies any slug. That is how an owner
|
|
18
|
+
* instance works: the manager is where plugins are written, so a plugin being
|
|
19
|
+
* built today has no slug in any ledger and a per-slug entitlement could not
|
|
20
|
+
* name it.
|
|
21
|
+
*
|
|
22
|
+
* @module server/services/pluginEntitlement
|
|
23
|
+
*/
|
|
24
|
+
import {createPublicKey, verify as cryptoVerify} from 'node:crypto';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import {trustedKey} from './pluginInstaller.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Canonical JSON: keys sorted at every depth, no whitespace, undefined dropped.
|
|
31
|
+
*
|
|
32
|
+
* A deliberate verbatim copy of the signer's, in dcms-marketplace's
|
|
33
|
+
* scripts/entitlement.js. The signature is over bytes, and a verifier that
|
|
34
|
+
* disagrees by one space rejects everything for a reason nobody can see. It is
|
|
35
|
+
* duplicated rather than shared because the signer is not, and must not be, a
|
|
36
|
+
* dependency of the CMS.
|
|
37
|
+
*
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function canonicalise(value) {
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
return '[' + value.map(canonicalise).join(',') + ']';
|
|
44
|
+
}
|
|
45
|
+
if (value && typeof value === 'object') {
|
|
46
|
+
const keys = Object.keys(value).filter(k => value[k] !== undefined).sort();
|
|
47
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalise(value[k])).join(',') + '}';
|
|
48
|
+
}
|
|
49
|
+
return JSON.stringify(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Does this entitlement cover the named plugin? */
|
|
53
|
+
export function coversSlug(entitlement, slug) {
|
|
54
|
+
return entitlement?.slug === '*' || entitlement?.slug === slug;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find the entitlement covering a plugin.
|
|
59
|
+
*
|
|
60
|
+
* Two places, in order:
|
|
61
|
+
*
|
|
62
|
+
* 1. `plugins/<slug>/.entitlement.json` - what an envelope carried in. One
|
|
63
|
+
* plugin, one licence, sitting beside the thing it licenses.
|
|
64
|
+
* 2. `config/entitlements/*.json` - INSTANCE level. This is where an owner
|
|
65
|
+
* wildcard lives, and it has to be here rather than in a plugin directory:
|
|
66
|
+
* a wildcard filed under one plugin would cover that plugin only, which
|
|
67
|
+
* defeats the whole point of it.
|
|
68
|
+
*
|
|
69
|
+
* A per-plugin file wins. It is the more specific claim, and it is the one a
|
|
70
|
+
* renewal writes.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} slug
|
|
73
|
+
* @returns {object|null}
|
|
74
|
+
*/
|
|
75
|
+
export function readEntitlement(slug) {
|
|
76
|
+
const own = path.join(path.resolve('plugins'), slug, '.entitlement.json');
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(fs.readFileSync(own, 'utf8'));
|
|
79
|
+
} catch { /* fall through to the instance level */ }
|
|
80
|
+
|
|
81
|
+
const dir = path.resolve('config', 'entitlements');
|
|
82
|
+
let names;
|
|
83
|
+
try {
|
|
84
|
+
names = fs.readdirSync(dir).filter(n => n.endsWith('.json')).sort();
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
for (const name of names) {
|
|
89
|
+
try {
|
|
90
|
+
const e = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
91
|
+
if (coversSlug(e, slug)) return e;
|
|
92
|
+
} catch { /* a malformed file is not an entitlement */ }
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Classify a plugin's entitlement.
|
|
99
|
+
*
|
|
100
|
+
* Returns one of:
|
|
101
|
+
* 'valid' load it
|
|
102
|
+
* 'grace' load it, and say so - a rental just past its term
|
|
103
|
+
* 'support-ended' load it - a perpetual licence whose updates have stopped
|
|
104
|
+
* 'expired' do NOT load - a rental past its grace period
|
|
105
|
+
* 'unlicensed' do NOT load - absent, malformed, or not signed by us
|
|
106
|
+
*
|
|
107
|
+
* @param {string} slug
|
|
108
|
+
* @param {object|null} [entitlement] - injectable for testing
|
|
109
|
+
* @param {Date} [now]
|
|
110
|
+
* @returns {{state: string, reason: string, entitlement: object|null}}
|
|
111
|
+
*/
|
|
112
|
+
export function classifyEntitlement(slug, entitlement = readEntitlement(slug), now = new Date()) {
|
|
113
|
+
if (!entitlement) {
|
|
114
|
+
return {state: 'unlicensed', reason: 'no entitlement file', entitlement: null};
|
|
115
|
+
}
|
|
116
|
+
if (!coversSlug(entitlement, slug)) {
|
|
117
|
+
return {state: 'unlicensed', reason: `entitlement is for "${entitlement.slug}"`, entitlement};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pem = trustedKey(entitlement.keyId);
|
|
121
|
+
if (!pem) {
|
|
122
|
+
return {state: 'unlicensed', reason: `unknown signing key "${entitlement.keyId}"`, entitlement};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const {signature, ...rest} = entitlement;
|
|
126
|
+
let ok = false;
|
|
127
|
+
try {
|
|
128
|
+
ok = cryptoVerify(null, Buffer.from(canonicalise(rest), 'utf8'),
|
|
129
|
+
createPublicKey(pem), Buffer.from(String(signature), 'base64'));
|
|
130
|
+
} catch {
|
|
131
|
+
ok = false;
|
|
132
|
+
}
|
|
133
|
+
if (!ok) return {state: 'unlicensed', reason: 'signature does not verify', entitlement};
|
|
134
|
+
|
|
135
|
+
const ms = now.getTime();
|
|
136
|
+
const graceMs = (Number(entitlement.gracePeriodDays) || 0) * 86_400_000;
|
|
137
|
+
|
|
138
|
+
if (entitlement.model === 'rental') {
|
|
139
|
+
const end = Date.parse(entitlement.expiresAt);
|
|
140
|
+
if (!Number.isFinite(end)) {
|
|
141
|
+
return {state: 'unlicensed', reason: 'rental with no expiry', entitlement};
|
|
142
|
+
}
|
|
143
|
+
if (ms <= end) return {state: 'valid', reason: '', entitlement};
|
|
144
|
+
if (ms <= end + graceMs) return {state: 'grace', reason: 'rental term ended', entitlement};
|
|
145
|
+
return {state: 'expired', reason: 'rental term and grace period ended', entitlement};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Perpetual. Support may have lapsed; the licence has not, and never will.
|
|
149
|
+
const support = Date.parse(entitlement.supportExpiresAt);
|
|
150
|
+
if (Number.isFinite(support) && ms > support) {
|
|
151
|
+
return {state: 'support-ended', reason: 'support and updates ended', entitlement};
|
|
152
|
+
}
|
|
153
|
+
return {state: 'valid', reason: '', entitlement};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The states that let a plugin load. */
|
|
157
|
+
const LOADABLE = new Set(['valid', 'grace', 'support-ended']);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* May this plugin load?
|
|
161
|
+
*
|
|
162
|
+
* @param {object} manifest
|
|
163
|
+
* @returns {{allowed: boolean, state: string, reason: string}}
|
|
164
|
+
*/
|
|
165
|
+
export function mayLoad(manifest) {
|
|
166
|
+
if (manifest?.closedSource !== true) {
|
|
167
|
+
return {allowed: true, state: 'not-required', reason: ''};
|
|
168
|
+
}
|
|
169
|
+
const {state, reason} = classifyEntitlement(manifest.name);
|
|
170
|
+
return {allowed: LOADABLE.has(state), state, reason};
|
|
171
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.dcmsplugin` envelope - one file a plugin travels in.
|
|
3
|
+
*
|
|
4
|
+
* A gzipped tar carrying three members at the top level:
|
|
5
|
+
*
|
|
6
|
+
* plugin.tar.gz the plugin directory, the same bytes the catalogue ships
|
|
7
|
+
* plugin.sig detached Ed25519 signature over plugin.tar.gz, base64.
|
|
8
|
+
* OPTIONAL: an export from a running CMS cannot be signed,
|
|
9
|
+
* because the marketplace key is offline and never on a
|
|
10
|
+
* customer's host.
|
|
11
|
+
* entitlement.json OPTIONAL: present on a paid download, absent on an export
|
|
12
|
+
*
|
|
13
|
+
* One format for all three delivery paths - catalogue bundle, paid download, and
|
|
14
|
+
* a plain export between two installations - so every one of them enters through
|
|
15
|
+
* the same installer and gets the same traversal guard, manifest validation and
|
|
16
|
+
* cleanup on failure. A second "just unzip it" path would be the one that skips a
|
|
17
|
+
* check.
|
|
18
|
+
*
|
|
19
|
+
* Tar is written and read here rather than shelled out to, because a CMS is not
|
|
20
|
+
* guaranteed to have the binary and because `extractTar` in pluginInstaller.js
|
|
21
|
+
* deliberately strips the leading path component - correct for a plugin tarball,
|
|
22
|
+
* wrong for an envelope whose members sit at the root. The two readers answer
|
|
23
|
+
* different questions and are kept apart on purpose.
|
|
24
|
+
*
|
|
25
|
+
* @module server/services/pluginEnvelope
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'fs/promises';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import zlib from 'zlib';
|
|
30
|
+
|
|
31
|
+
const BLOCK = 512;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A ustar header for one member.
|
|
35
|
+
*
|
|
36
|
+
* Names are capped at 100 bytes, which is the format's own limit and not worth
|
|
37
|
+
* the GNU long-name extension for three fixed filenames.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @param {number} size
|
|
41
|
+
* @returns {Buffer}
|
|
42
|
+
*/
|
|
43
|
+
function header(name, size) {
|
|
44
|
+
if (Buffer.byteLength(name) > 100) throw new Error(`envelope member name too long: ${name}`);
|
|
45
|
+
const h = Buffer.alloc(BLOCK);
|
|
46
|
+
h.write(name, 0, 100, 'utf8');
|
|
47
|
+
h.write('0000644\0', 100); // mode
|
|
48
|
+
h.write('0000000\0', 108); // uid
|
|
49
|
+
h.write('0000000\0', 116); // gid
|
|
50
|
+
h.write(size.toString(8).padStart(11, '0') + '\0', 124);
|
|
51
|
+
// Fixed mtime: the envelope's bytes should depend on its contents and
|
|
52
|
+
// nothing else, so the same plugin exported twice compares equal.
|
|
53
|
+
h.write('00000000000\0', 136);
|
|
54
|
+
h.write(' ', 148); // checksum placeholder
|
|
55
|
+
h.write('0', 156); // type: regular file
|
|
56
|
+
h.write('ustar\0', 257);
|
|
57
|
+
h.write('00', 263);
|
|
58
|
+
|
|
59
|
+
let sum = 0;
|
|
60
|
+
for (const b of h) sum += b;
|
|
61
|
+
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
|
62
|
+
return h;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pad a buffer up to the next 512-byte boundary. */
|
|
66
|
+
function pad(buf) {
|
|
67
|
+
const over = buf.length % BLOCK;
|
|
68
|
+
return over === 0 ? buf : Buffer.concat([buf, Buffer.alloc(BLOCK - over)]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What never travels in an export.
|
|
73
|
+
*
|
|
74
|
+
* `data/` is the site's runtime state, not the plugin's - the same carve-out the
|
|
75
|
+
* npm package and the catalogue build both make. `CLAUDE.md` is working notes.
|
|
76
|
+
*/
|
|
77
|
+
export const EXPORT_EXCLUDES = new Set(['data', 'backups', 'node_modules', 'CLAUDE.md', '.DS_Store']);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Tar a plugin directory into the shape `pluginInstaller.extractTar()` expects:
|
|
81
|
+
* every entry under a single leading component, which that parser strips.
|
|
82
|
+
*
|
|
83
|
+
* Sorted, with a fixed mtime, so the same plugin exported twice produces
|
|
84
|
+
* identical bytes - which is what lets two exports be compared at all.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} dir - absolute plugin directory
|
|
87
|
+
* @param {string} prefix - the leading component, normally the slug
|
|
88
|
+
* @param {{includeData?: boolean}} [opts]
|
|
89
|
+
* @returns {Promise<Buffer>} gzipped tar
|
|
90
|
+
*/
|
|
91
|
+
export async function tarDirectory(dir, prefix, {includeData = false} = {}) {
|
|
92
|
+
const files = [];
|
|
93
|
+
async function walk(abs, rel) {
|
|
94
|
+
const entries = await fs.readdir(abs, {withFileTypes: true});
|
|
95
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
96
|
+
const skip = EXPORT_EXCLUDES.has(e.name) && !(includeData && e.name === 'data');
|
|
97
|
+
if (skip) continue;
|
|
98
|
+
const next = rel ? `${rel}/${e.name}` : e.name;
|
|
99
|
+
// Symlinks are neither followed nor recorded: extractTar ignores them
|
|
100
|
+
// on the way in, so carrying one would only ever be a surprise.
|
|
101
|
+
if (e.isSymbolicLink()) continue;
|
|
102
|
+
if (e.isDirectory()) await walk(path.join(abs, e.name), next);
|
|
103
|
+
else if (e.isFile()) files.push([next, await fs.readFile(path.join(abs, e.name))]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await walk(dir, '');
|
|
107
|
+
|
|
108
|
+
const chunks = [];
|
|
109
|
+
for (const [rel, body] of files) {
|
|
110
|
+
chunks.push(header(`${prefix}/${rel}`, body.length), pad(body));
|
|
111
|
+
}
|
|
112
|
+
chunks.push(Buffer.alloc(BLOCK * 2));
|
|
113
|
+
return zlib.gzipSync(Buffer.concat(chunks), {level: 9});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build an envelope.
|
|
118
|
+
*
|
|
119
|
+
* @param {{tarball: Buffer, signature?: string|null, entitlement?: object|null}} parts
|
|
120
|
+
* @returns {Buffer} gzipped tar
|
|
121
|
+
*/
|
|
122
|
+
export function packEnvelope({tarball, signature = null, entitlement = null}) {
|
|
123
|
+
if (!Buffer.isBuffer(tarball) || !tarball.length) {
|
|
124
|
+
throw new Error('packEnvelope: tarball is required');
|
|
125
|
+
}
|
|
126
|
+
const members = [['plugin.tar.gz', tarball]];
|
|
127
|
+
if (signature) members.push(['plugin.sig', Buffer.from(String(signature), 'utf8')]);
|
|
128
|
+
if (entitlement) {
|
|
129
|
+
members.push(['entitlement.json', Buffer.from(JSON.stringify(entitlement, null, 2) + '\n', 'utf8')]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const chunks = [];
|
|
133
|
+
for (const [name, body] of members) {
|
|
134
|
+
chunks.push(header(name, body.length), pad(body));
|
|
135
|
+
}
|
|
136
|
+
chunks.push(Buffer.alloc(BLOCK * 2)); // end-of-archive
|
|
137
|
+
return zlib.gzipSync(Buffer.concat(chunks), {level: 9});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read the manifest out of a plugin tarball, without extracting it.
|
|
142
|
+
*
|
|
143
|
+
* The slug an upload installs under comes from here, never from the uploaded
|
|
144
|
+
* filename: a file can be renamed by anyone, a manifest is the thing the
|
|
145
|
+
* installer then validates against what was extracted.
|
|
146
|
+
*
|
|
147
|
+
* @param {Buffer} tarball - gzipped tar with a single leading component
|
|
148
|
+
* @returns {object|null} the parsed manifest, or null if there is not one
|
|
149
|
+
*/
|
|
150
|
+
export function readManifestFromTarball(tarball) {
|
|
151
|
+
let raw;
|
|
152
|
+
try {
|
|
153
|
+
raw = zlib.gunzipSync(tarball);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
let offset = 0;
|
|
158
|
+
let zeros = 0;
|
|
159
|
+
while (offset + BLOCK <= raw.length) {
|
|
160
|
+
const h = raw.subarray(offset, offset + BLOCK);
|
|
161
|
+
if (h.every(b => b === 0)) {
|
|
162
|
+
if (++zeros >= 2) break;
|
|
163
|
+
offset += BLOCK;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
zeros = 0;
|
|
167
|
+
offset += BLOCK;
|
|
168
|
+
const nul = h.indexOf(0);
|
|
169
|
+
const name = h.subarray(0, nul === -1 ? 100 : Math.min(nul, 100)).toString('utf8');
|
|
170
|
+
const size = parseInt(h.subarray(124, 136).toString('utf8').replace(/\0/g, '').trim(), 8) || 0;
|
|
171
|
+
// `<slug>/plugin.json` and nothing deeper - a plugin.json further down
|
|
172
|
+
// the tree belongs to something else.
|
|
173
|
+
if (/^[^/]+\/plugin\.json$/.test(name)) {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(raw.subarray(offset, offset + size).toString('utf8'));
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read an envelope.
|
|
187
|
+
*
|
|
188
|
+
* Refuses anything it does not recognise rather than guessing: an envelope is
|
|
189
|
+
* our own format, so a member we did not write is a sign the file is not what
|
|
190
|
+
* the caller thinks it is.
|
|
191
|
+
*
|
|
192
|
+
* @param {Buffer} buffer - the .dcmsplugin file
|
|
193
|
+
* @returns {{tarball: Buffer, signature: string|null, entitlement: object|null}}
|
|
194
|
+
*/
|
|
195
|
+
export function readEnvelope(buffer) {
|
|
196
|
+
let raw;
|
|
197
|
+
try {
|
|
198
|
+
raw = zlib.gunzipSync(buffer);
|
|
199
|
+
} catch {
|
|
200
|
+
throw new Error('Not a .dcmsplugin file: it is not gzipped');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const found = new Map();
|
|
204
|
+
let offset = 0;
|
|
205
|
+
let zeros = 0;
|
|
206
|
+
while (offset + BLOCK <= raw.length) {
|
|
207
|
+
const h = raw.subarray(offset, offset + BLOCK);
|
|
208
|
+
if (h.every(b => b === 0)) {
|
|
209
|
+
if (++zeros >= 2) break;
|
|
210
|
+
offset += BLOCK;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
zeros = 0;
|
|
214
|
+
offset += BLOCK;
|
|
215
|
+
|
|
216
|
+
const nul = h.indexOf(0);
|
|
217
|
+
const name = h.subarray(0, nul === -1 ? 100 : Math.min(nul, 100)).toString('utf8');
|
|
218
|
+
const size = parseInt(h.subarray(124, 136).toString('utf8').replace(/\0/g, '').trim(), 8) || 0;
|
|
219
|
+
// Members sit at the root - no directories, no nesting. Anything else is
|
|
220
|
+
// not an envelope we wrote.
|
|
221
|
+
if (name && !name.includes('/')) found.set(name, raw.subarray(offset, offset + size));
|
|
222
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const tarball = found.get('plugin.tar.gz');
|
|
226
|
+
if (!tarball) throw new Error('Not a .dcmsplugin file: no plugin.tar.gz inside');
|
|
227
|
+
|
|
228
|
+
let entitlement = null;
|
|
229
|
+
if (found.has('entitlement.json')) {
|
|
230
|
+
try {
|
|
231
|
+
entitlement = JSON.parse(found.get('entitlement.json').toString('utf8'));
|
|
232
|
+
} catch {
|
|
233
|
+
throw new Error('The entitlement inside this file is not readable JSON');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
tarball: Buffer.from(tarball),
|
|
239
|
+
signature: found.has('plugin.sig') ? found.get('plugin.sig').toString('utf8').trim() : null,
|
|
240
|
+
entitlement
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -7,8 +7,16 @@
|
|
|
7
7
|
* valid names), paths are normalised and containment-checked after symlink
|
|
8
8
|
* resolution, and only known text extensions are editable.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* A plugin marked `"closedSource": true` in its manifest is refused - licensed
|
|
11
|
+
* code is distributed to a site so it can RUN there, not so it can be read out
|
|
12
|
+
* of it, and the editor was handing over the server source in full. The one
|
|
13
|
+
* exception is a SUPER-ADMIN (role level 0), which on the fleet exists only on
|
|
14
|
+
* the manager: the people who publish the paid plugins are the people who need
|
|
15
|
+
* to read and edit them. Customer sites are all level 1, so nothing changes
|
|
16
|
+
* there - see the `allowLicensed` argument, set from the request's user.
|
|
17
|
+
*
|
|
18
|
+
* Thrown errors carry `statusCode` (400 invalid, 403 licensed, 404 missing,
|
|
19
|
+
* 413 too large) for direct mapping in the route layer.
|
|
12
20
|
*/
|
|
13
21
|
import fs from 'fs/promises';
|
|
14
22
|
import path from 'path';
|
|
@@ -40,7 +48,7 @@ function fail(statusCode, message) {
|
|
|
40
48
|
* @param {string} name
|
|
41
49
|
* @returns {Promise<string>} Absolute plugin directory
|
|
42
50
|
*/
|
|
43
|
-
async function resolvePluginDir(name) {
|
|
51
|
+
async function resolvePluginDir(name, allowLicensed = false) {
|
|
44
52
|
if (!NAME_PATTERN.test(String(name || ''))) throw fail(400, 'Invalid plugin name.');
|
|
45
53
|
const dir = path.join(PLUGINS_DIR, name);
|
|
46
54
|
try {
|
|
@@ -49,9 +57,42 @@ async function resolvePluginDir(name) {
|
|
|
49
57
|
} catch {
|
|
50
58
|
throw fail(404, `Plugin "${name}" not found.`);
|
|
51
59
|
}
|
|
60
|
+
|
|
61
|
+
// Every operation in this module goes through here, which is why the check
|
|
62
|
+
// lives here and not in four places that could drift apart.
|
|
63
|
+
if (!allowLicensed && await isClosedSource(dir)) {
|
|
64
|
+
throw fail(403, `"${name}" is a licensed plugin. Its source is not editable here.`);
|
|
65
|
+
}
|
|
52
66
|
return dir;
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Is this plugin's source off limits to the code editor?
|
|
71
|
+
*
|
|
72
|
+
* A licensed plugin is distributed to a site so it can RUN there, not so it
|
|
73
|
+
* can be read and copied - and the editor made it readable AND writable to
|
|
74
|
+
* anyone holding `plugins.develop`, which on a customer's own site is the
|
|
75
|
+
* customer. Writable is the worse half: the licence check is in that file.
|
|
76
|
+
*
|
|
77
|
+
* Declared by the plugin itself (`"closedSource": true` in plugin.json) so the
|
|
78
|
+
* flag travels with it wherever it is distributed, rather than depending on a
|
|
79
|
+
* list somewhere that a new paid plugin would have to be remembered into.
|
|
80
|
+
*
|
|
81
|
+
* A manifest that cannot be read is treated as closed. This gate failing open
|
|
82
|
+
* would publish the very thing it exists to protect.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} dir - absolute plugin directory
|
|
85
|
+
* @returns {Promise<boolean>}
|
|
86
|
+
*/
|
|
87
|
+
async function isClosedSource(dir) {
|
|
88
|
+
try {
|
|
89
|
+
const manifest = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
|
|
90
|
+
return manifest.closedSource === true;
|
|
91
|
+
} catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
/**
|
|
56
97
|
* Resolve a relative file path inside the plugin dir, or throw.
|
|
57
98
|
* Rejects absolute paths, traversal, hidden segments and unknown extensions.
|
|
@@ -98,8 +139,8 @@ async function resolveFilePath(pluginDir, relPath) {
|
|
|
98
139
|
* @param {string} name
|
|
99
140
|
* @returns {Promise<Array<{path: string, size: number, modified: string}>>}
|
|
100
141
|
*/
|
|
101
|
-
export async function listPluginFiles(name) {
|
|
102
|
-
const pluginDir = await resolvePluginDir(name);
|
|
142
|
+
export async function listPluginFiles(name, {allowLicensed = false} = {}) {
|
|
143
|
+
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
103
144
|
const files = [];
|
|
104
145
|
|
|
105
146
|
async function walk(dir) {
|
|
@@ -132,8 +173,8 @@ export async function listPluginFiles(name) {
|
|
|
132
173
|
* @param {string} relPath
|
|
133
174
|
* @returns {Promise<{path: string, content: string}>}
|
|
134
175
|
*/
|
|
135
|
-
export async function readPluginFile(name, relPath) {
|
|
136
|
-
const pluginDir = await resolvePluginDir(name);
|
|
176
|
+
export async function readPluginFile(name, relPath, {allowLicensed = false} = {}) {
|
|
177
|
+
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
137
178
|
const abs = await resolveFilePath(pluginDir, relPath);
|
|
138
179
|
|
|
139
180
|
let stat;
|
|
@@ -158,8 +199,8 @@ export async function readPluginFile(name, relPath) {
|
|
|
158
199
|
* @param {string} content
|
|
159
200
|
* @returns {Promise<{path: string, restartRequired: boolean}>}
|
|
160
201
|
*/
|
|
161
|
-
export async function writePluginFile(name, relPath, content) {
|
|
162
|
-
const pluginDir = await resolvePluginDir(name);
|
|
202
|
+
export async function writePluginFile(name, relPath, content, {allowLicensed = false} = {}) {
|
|
203
|
+
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
163
204
|
const abs = await resolveFilePath(pluginDir, relPath);
|
|
164
205
|
|
|
165
206
|
if (typeof content !== 'string') throw fail(400, 'Content must be a string.');
|
|
@@ -198,8 +239,8 @@ export async function writePluginFile(name, relPath, content) {
|
|
|
198
239
|
* @param {string} relPath
|
|
199
240
|
* @returns {Promise<{path: string}>}
|
|
200
241
|
*/
|
|
201
|
-
export async function deletePluginFile(name, relPath) {
|
|
202
|
-
const pluginDir = await resolvePluginDir(name);
|
|
242
|
+
export async function deletePluginFile(name, relPath, {allowLicensed = false} = {}) {
|
|
243
|
+
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
203
244
|
const abs = await resolveFilePath(pluginDir, relPath);
|
|
204
245
|
const rel = path.relative(pluginDir, abs).split(path.sep).join('/');
|
|
205
246
|
|