domma-cms 1.7.0 → 1.7.2

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 (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/CLAUDE.md +30 -0
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/mail-compose.js +1 -0
  5. package/admin/js/lib/wordpress-import-arrange.js +1 -0
  6. package/admin/js/templates/call-new.html +1 -1
  7. package/admin/js/templates/docs/usage-tools.html +13 -1
  8. package/admin/js/templates/wordpress-import.html +223 -0
  9. package/admin/js/views/call-new.js +1 -1
  10. package/admin/js/views/contacts.js +15 -15
  11. package/admin/js/views/form-submissions.js +2 -2
  12. package/admin/js/views/index.js +1 -1
  13. package/admin/js/views/wordpress-import.js +38 -0
  14. package/package.json +1 -1
  15. package/plugins/_lib/admin/mail/compose-window.js +3 -1
  16. package/plugins/_lib/admin/mail/identity.js +18 -3
  17. package/plugins/_lib/admin/mail/reader-view.js +32 -3
  18. package/plugins/blog/CLAUDE.md +4 -0
  19. package/plugins/blog/admin/lib/email.js +191 -0
  20. package/plugins/blog/admin/views/comments.js +3 -1
  21. package/plugins/blog/plugin.json +2 -2
  22. package/plugins/blog/plugin.public.js +1 -1
  23. package/plugins/blog/tests/email.test.js +69 -0
  24. package/plugins/booking/CLAUDE.md +4 -0
  25. package/plugins/booking/admin/lib/email.js +191 -0
  26. package/plugins/booking/admin/views/panels.js +7 -3
  27. package/plugins/booking/plugin.json +1 -1
  28. package/plugins/booking/tests/email.test.js +69 -0
  29. package/plugins/free-tier.lock.json +27 -19
  30. package/plugins/shopping-cart/CLAUDE.md +4 -0
  31. package/plugins/shopping-cart/admin/lib/email.js +191 -0
  32. package/plugins/shopping-cart/admin/views/orders.js +11 -3
  33. package/plugins/shopping-cart/plugin.json +2 -2
  34. package/plugins/shopping-cart/tests/email.test.js +69 -0
  35. package/plugins/support/CLAUDE.md +4 -0
  36. package/plugins/support/admin/lib/email.js +191 -0
  37. package/plugins/support/admin/views/desk.js +4 -1
  38. package/plugins/support/admin/views/inbox.js +6 -2
  39. package/plugins/support/plugin.json +2 -2
  40. package/plugins/support/tests/email.test.js +69 -0
  41. package/server/routes/api/wordpress-import.js +220 -0
  42. package/server/server.js +12 -0
  43. package/server/services/hooks.js +1 -1
  44. package/server/services/permissionRegistry.js +10 -0
  45. package/server/services/roles.js +1 -1
  46. package/server/services/tools.js +2 -1
  47. package/server/services/wpImport/convert.js +491 -0
  48. package/server/services/wpImport/fetch.js +264 -0
  49. package/server/services/wpImport/index.js +832 -0
  50. package/server/services/wpImport/plan.js +549 -0
  51. package/server/services/wpImport/store.js +242 -0
  52. package/server/services/wpImport/wxr.js +167 -0
  53. package/server/services/wpImport/xml.js +261 -0
  54. package/server/services/wpImport/zip.js +142 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * WordPress import - what it keeps on disk, all under content/imports/wordpress/:
3
+ *
4
+ * map.json per WordPress site (sourceKey): what each WordPress id
5
+ * became here, with a fingerprint of what was written -
6
+ * how a second run updates instead of duplicating, and
7
+ * notices a page edited here since
8
+ * {sources: {<key>: {items: {key: {...}}, redirects: {key: {id, from, to}},
9
+ * query: {p, page_id, attachment_id}, updatedAt}}}
10
+ * history.json the runs, newest first: [{id, at, by, source, siteTitle, file, status, summary}]
11
+ * reports/<id>.json each run's full report
12
+ * running.json the run in progress - a restart finds it and records the run as failed
13
+ * staging/<id>/ an upload waiting for its run (export.xml, uploads.zip, meta.json) -
14
+ * removed after the run, when discarded, or a day later
15
+ * The whole folder is kept out of the site's git repo (allowInGitignore): the history and
16
+ * reports name who ran each import and the commenters.
17
+ *
18
+ * @module services/wpImport/store
19
+ */
20
+ import fs from 'node:fs/promises';
21
+ import fsSync from 'node:fs';
22
+ import path from 'node:path';
23
+ import crypto from 'node:crypto';
24
+
25
+ export const importDir = () => path.resolve('content', 'imports', 'wordpress');
26
+ const stagingRoot = () => path.join(importDir(), 'staging');
27
+ const STAGING_TTL_MS = 24 * 60 * 60 * 1000;
28
+ export const HISTORY_MAX = 50;
29
+
30
+ const ID_RE = /^[a-z0-9]{6,40}$/;
31
+ export const isId = (id) => ID_RE.test(String(id || ''));
32
+ export const newId = () => `${Date.now().toString(36)}${crypto.randomBytes(5).toString('hex')}`;
33
+
34
+ async function readJson(file, empty) {
35
+ try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return structuredClone(empty); }
36
+ }
37
+
38
+ async function writeJson(file, value) {
39
+ await fs.mkdir(path.dirname(file), {recursive: true});
40
+ const tmp = `${file}.${process.pid}.tmp`;
41
+ await fs.writeFile(tmp, JSON.stringify(value, null, 2) + '\n');
42
+ await fs.rename(tmp, file);
43
+ }
44
+
45
+ // ---- The mapping ----------------------------------------------------------
46
+
47
+ let mapCache = null;
48
+
49
+ export async function readMap() {
50
+ const m = await readJson(path.join(importDir(), 'map.json'), {sources: {}});
51
+ if (!m.sources || typeof m.sources !== 'object') m.sources = {};
52
+ mapCache = m;
53
+ return m;
54
+ }
55
+
56
+ /** The mapping for one WordPress site ({} when it was never imported). */
57
+ export async function mappingFor(source) {
58
+ const m = await readMap();
59
+ return m.sources[source] || {items: {}, redirects: {}, query: {}};
60
+ }
61
+
62
+ let mapWrites = Promise.resolve();
63
+
64
+ /** Save one site's mapping. Writes are queued: the media downloads run side by side and each checkpoints. */
65
+ export function saveMapping(source, mapping) {
66
+ const snapshot = {...mapping, items: {...mapping.items}, redirects: {...mapping.redirects}, updatedAt: new Date().toISOString()};
67
+ const write = mapWrites.then(async () => {
68
+ const m = await readMap();
69
+ m.sources[source] = snapshot;
70
+ await writeJson(path.join(importDir(), 'map.json'), m);
71
+ mapCache = m;
72
+ });
73
+ mapWrites = write.catch(() => {});
74
+ return write;
75
+ }
76
+
77
+ let mapMtime = -1;
78
+ let mapCheckedAt = 0;
79
+
80
+ /**
81
+ * Synchronous, cached - for the request hook that answers ?p=12. The file is
82
+ * looked at again at most every few seconds, so a mapping written by another
83
+ * process (or restored from a backup) is picked up without a restart.
84
+ */
85
+ export function queryTarget(param, id) {
86
+ const now = Date.now();
87
+ if (!mapCache || now - mapCheckedAt > 3000) {
88
+ mapCheckedAt = now;
89
+ const file = path.join(importDir(), 'map.json');
90
+ let mtime = 0;
91
+ try { mtime = fsSync.statSync(file).mtimeMs; } catch { /* no imports yet */ }
92
+ if (!mapCache || mtime !== mapMtime) {
93
+ mapMtime = mtime;
94
+ try { mapCache = mtime ? JSON.parse(fsSync.readFileSync(file, 'utf8')) : {sources: {}}; } catch { mapCache = {sources: {}}; }
95
+ }
96
+ }
97
+ for (const src of Object.values(mapCache.sources || {})) {
98
+ const t = src?.query?.[param]?.[id];
99
+ if (typeof t === 'string' && t.startsWith('/') && !t.startsWith('//') && !/[\\\s]/.test(t)) return t;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ export function forgetMapCache() { mapCache = null; }
105
+
106
+ /**
107
+ * A page this import made was moved here (renamed in the page editor): follow
108
+ * it, so the next run updates it where it now is instead of thinking it was
109
+ * deleted, and ?page_id= keeps finding it.
110
+ */
111
+ export async function followRename(from, to) {
112
+ if (!from || !to || from === to) return false;
113
+ const m = await readMap();
114
+ let changed = false;
115
+ for (const src of Object.values(m.sources)) {
116
+ for (const it of Object.values(src.items || {})) {
117
+ if (it?.path === from) { it.path = to; changed = true; }
118
+ }
119
+ for (const list of Object.values(src.query || {})) {
120
+ for (const [id, t] of Object.entries(list || {})) if (t === from) { list[id] = to; changed = true; }
121
+ }
122
+ }
123
+ if (changed) await writeJson(path.join(importDir(), 'map.json'), m);
124
+ return changed;
125
+ }
126
+
127
+ // ---- A run in progress, on disk - so a restart mid-run is noticed ----
128
+
129
+ const runningFile = () => path.join(importDir(), 'running.json');
130
+
131
+ export async function markRunning(run) { await writeJson(runningFile(), run); }
132
+ export async function clearRunning() { await fs.rm(runningFile(), {force: true}); }
133
+
134
+ /**
135
+ * A run the site stopped in the middle of (a restart, a crash): recorded in
136
+ * the history as failed. What it made is already in the mapping (it is saved
137
+ * as the run goes), so running the same export again finishes the job
138
+ * without duplicating anything. Called once at boot.
139
+ */
140
+ export async function recoverInterrupted() {
141
+ let run;
142
+ try { run = JSON.parse(await fs.readFile(runningFile(), 'utf8')); } catch { return null; }
143
+ await clearRunning();
144
+ if (!isId(run?.id)) return null;
145
+ await recordFailedRun(run, 'The site restarted while this import was running. What it had already made is kept; upload the export again (or use the one still waiting) and run it to finish - nothing is made twice.');
146
+ return run;
147
+ }
148
+
149
+ /** A run that stopped, in the history with the reason. */
150
+ export async function recordFailedRun(run, reason) {
151
+ const entry = {id: run.id, at: new Date().toISOString(), by: run.by || '', source: run.source || '', siteTitle: run.siteTitle || '',
152
+ siteUrl: run.siteUrl || '', file: run.file || '', status: 'failed', durationMs: 0,
153
+ summary: {kinds: {}, comments: 0, redirects: {created: 0, updated: 0, skipped: 0, failed: 0}, failed: 0, rebuild: 0}};
154
+ await addHistory(entry, {run: entry, options: {}, items: [], redirects: [], notes: [reason], blogBase: ''});
155
+ }
156
+
157
+ // ---- History --------------------------------------------------------------
158
+
159
+ export async function listHistory() {
160
+ const h = await readJson(path.join(importDir(), 'history.json'), {runs: []});
161
+ return Array.isArray(h.runs) ? h.runs : [];
162
+ }
163
+
164
+ export async function addHistory(run, report) {
165
+ await writeJson(path.join(importDir(), 'reports', `${run.id}.json`), report);
166
+ const runs = [run, ...(await listHistory()).filter(r => r.id !== run.id)];
167
+ const keep = runs.slice(0, HISTORY_MAX);
168
+ for (const gone of runs.slice(HISTORY_MAX)) await fs.rm(path.join(importDir(), 'reports', `${gone.id}.json`), {force: true});
169
+ await writeJson(path.join(importDir(), 'history.json'), {runs: keep});
170
+ }
171
+
172
+ export async function getReport(id) {
173
+ if (!isId(id)) return null;
174
+ try { return JSON.parse(await fs.readFile(path.join(importDir(), 'reports', `${id}.json`), 'utf8')); } catch { return null; }
175
+ }
176
+
177
+ /** Remove a run from the history (its report too). The imported content stays. */
178
+ export async function removeHistory(id) {
179
+ if (!isId(id)) return false;
180
+ const runs = await listHistory();
181
+ if (!runs.some(r => r.id === id)) return false;
182
+ await writeJson(path.join(importDir(), 'history.json'), {runs: runs.filter(r => r.id !== id)});
183
+ await fs.rm(path.join(importDir(), 'reports', `${id}.json`), {force: true});
184
+ return true;
185
+ }
186
+
187
+ // ---- Staging --------------------------------------------------------------
188
+
189
+ export function stagingPath(id, file = '') {
190
+ if (!isId(id)) throw new Error('Not an import id.');
191
+ if (file && !/^[a-z0-9.-]+$/.test(file)) throw new Error('Not a staging file.');
192
+ return path.join(stagingRoot(), id, file);
193
+ }
194
+
195
+ export async function createStaging(meta) {
196
+ const id = newId();
197
+ await fs.mkdir(stagingPath(id), {recursive: true});
198
+ await writeJson(stagingPath(id, 'meta.json'), {...meta, id, at: new Date().toISOString()});
199
+ return id;
200
+ }
201
+
202
+ export async function stagingMeta(id) {
203
+ if (!isId(id)) return null;
204
+ try {
205
+ const meta = JSON.parse(await fs.readFile(stagingPath(id, 'meta.json'), 'utf8'));
206
+ if (Date.now() - Date.parse(meta.at) > STAGING_TTL_MS) { await dropStaging(id); return null; }
207
+ return meta;
208
+ } catch { return null; }
209
+ }
210
+
211
+ export async function updateStagingMeta(id, patch) {
212
+ const meta = await stagingMeta(id);
213
+ if (!meta) return null;
214
+ const next = {...meta, ...patch};
215
+ await writeJson(stagingPath(id, 'meta.json'), next);
216
+ return next;
217
+ }
218
+
219
+ export async function dropStaging(id) {
220
+ if (!isId(id)) return;
221
+ await fs.rm(stagingPath(id), {recursive: true, force: true});
222
+ }
223
+
224
+ /** Uploads waiting for a run, newest first; anything over a day old is removed. */
225
+ export async function listStaging() {
226
+ let names = [];
227
+ try { names = await fs.readdir(stagingRoot()); } catch { return []; }
228
+ const out = [];
229
+ for (const n of names) {
230
+ if (!isId(n)) continue;
231
+ const meta = await stagingMeta(n);
232
+ if (meta) out.push(meta);
233
+ }
234
+ return out.sort((a, b) => String(b.at).localeCompare(String(a.at)));
235
+ }
236
+
237
+ /** A fingerprint of what an import wrote - compared on the next run to notice edits made here. */
238
+ export function fingerprint(...parts) {
239
+ const h = crypto.createHash('sha256');
240
+ for (const p of parts) h.update(String(p ?? '').replace(/\r\n?/g, '\n').trim()).update('\u0000');
241
+ return h.digest('hex').slice(0, 32);
242
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A WordPress export (WXR 1.0-1.2) as plain data - what the planner and the
3
+ * runner work from. Reads the tree from ./xml.js; does no I/O.
4
+ *
5
+ * {site: {title, link, baseSiteUrl, baseBlogUrl, wxrVersion, hosts[]},
6
+ * authors[], categories[], tags[], menus[],
7
+ * items: [{id, type, title, slug, status, parent, order, date, dateGmt, link, guid,
8
+ * creator, content, excerpt, password, attachmentUrl, meta{}, terms[], comments[]}]}
9
+ *
10
+ * @module services/wpImport/wxr
11
+ */
12
+ import {child, children, parseXml, textOf, XmlError} from './xml.js';
13
+
14
+ /** Item types the importer acts on; everything else is counted and listed as skipped. */
15
+ export const KNOWN_TYPES = new Set(['page', 'post', 'attachment', 'nav_menu_item']);
16
+
17
+ const int = (v) => {
18
+ const n = Number.parseInt(String(v ?? '').trim(), 10);
19
+ return Number.isFinite(n) ? n : 0;
20
+ };
21
+
22
+ /** The hostname of a URL ('' when it is not one), lower case, without "www.". */
23
+ export function hostOf(url) {
24
+ try {
25
+ const u = new URL(String(url || '').trim());
26
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
27
+ return u.hostname.toLowerCase();
28
+ } catch { return ''; }
29
+ }
30
+
31
+ const bareHost = (h) => String(h || '').toLowerCase().replace(/^www\./, '');
32
+
33
+ /**
34
+ * Read a WXR document.
35
+ *
36
+ * @param {string} text - the XML
37
+ * @returns {Promise<object>} the export as data
38
+ * @throws {XmlError} when it is not XML, or not a WordPress export
39
+ */
40
+ export async function readWxr(text) {
41
+ const root = await parseXml(text);
42
+ return wxrFromTree(root);
43
+ }
44
+
45
+ /** @param {object} root - from parseXml() */
46
+ export function wxrFromTree(root) {
47
+ if (root?.name !== 'rss') throw new XmlError('This is not a WordPress export: it is not an RSS document');
48
+ const channel = child(root, 'channel');
49
+ if (!channel) throw new XmlError('This is not a WordPress export: it has no channel');
50
+ const wxrVersion = textOf(channel, 'wp:wxr_version');
51
+ if (!wxrVersion) throw new XmlError('This is not a WordPress export: it has no wp:wxr_version (export it from Tools › Export in WordPress)');
52
+
53
+ const site = {
54
+ title: textOf(channel, 'title'),
55
+ link: textOf(channel, 'link'),
56
+ description: textOf(channel, 'description'),
57
+ language: textOf(channel, 'language'),
58
+ baseSiteUrl: textOf(channel, 'wp:base_site_url'),
59
+ baseBlogUrl: textOf(channel, 'wp:base_blog_url'),
60
+ wxrVersion
61
+ };
62
+
63
+ const authors = children(channel, 'wp:author').map(a => ({
64
+ id: int(textOf(a, 'wp:author_id')),
65
+ login: textOf(a, 'wp:author_login'),
66
+ email: textOf(a, 'wp:author_email'),
67
+ displayName: textOf(a, 'wp:author_display_name') || textOf(a, 'wp:author_login')
68
+ }));
69
+
70
+ const categories = children(channel, 'wp:category').map(c => ({
71
+ id: int(textOf(c, 'wp:term_id')),
72
+ slug: textOf(c, 'wp:category_nicename'),
73
+ parent: textOf(c, 'wp:category_parent'),
74
+ name: textOf(c, 'wp:cat_name'),
75
+ description: textOf(c, 'wp:category_description')
76
+ })).filter(c => c.slug);
77
+
78
+ const tags = children(channel, 'wp:tag').map(t => ({
79
+ id: int(textOf(t, 'wp:term_id')),
80
+ slug: textOf(t, 'wp:tag_slug'),
81
+ name: textOf(t, 'wp:tag_name'),
82
+ description: textOf(t, 'wp:tag_description')
83
+ })).filter(t => t.slug);
84
+
85
+ // nav_menu terms: the menus themselves (their items are nav_menu_item posts).
86
+ const menus = children(channel, 'wp:term')
87
+ .filter(t => textOf(t, 'wp:term_taxonomy') === 'nav_menu')
88
+ .map(t => ({id: int(textOf(t, 'wp:term_id')), slug: textOf(t, 'wp:term_slug'), name: textOf(t, 'wp:term_name')}))
89
+ .filter(m => m.slug);
90
+
91
+ const items = children(channel, 'item').map(readItem);
92
+
93
+ // A menu named only on its items (WXR 1.0 has no wp:term for it).
94
+ for (const it of items) {
95
+ if (it.type !== 'nav_menu_item') continue;
96
+ const t = it.terms.find(x => x.domain === 'nav_menu');
97
+ if (t && !menus.some(m => m.slug === t.slug)) menus.push({id: 0, slug: t.slug, name: t.name || t.slug});
98
+ }
99
+ // Categories and tags named only on posts (a partial export). Sets: big exports have thousands of each.
100
+ const catSlugs = new Set(categories.map(c => c.slug));
101
+ const tagSlugs = new Set(tags.map(t => t.slug));
102
+ for (const it of items) {
103
+ for (const t of it.terms) {
104
+ if (t.domain === 'category' && !catSlugs.has(t.slug)) { catSlugs.add(t.slug); categories.push({id: 0, slug: t.slug, parent: '', name: t.name || t.slug, description: ''}); }
105
+ if (t.domain === 'post_tag' && !tagSlugs.has(t.slug)) { tagSlugs.add(t.slug); tags.push({id: 0, slug: t.slug, name: t.name || t.slug, description: ''}); }
106
+ }
107
+ }
108
+
109
+ const hostSet = new Set();
110
+ for (const u of [site.baseSiteUrl, site.baseBlogUrl, site.link]) {
111
+ const h = hostOf(u);
112
+ if (h) { hostSet.add(h); hostSet.add(bareHost(h)); hostSet.add(`www.${bareHost(h)}`); }
113
+ }
114
+ site.hosts = [...hostSet];
115
+ return {site, authors, categories, tags, menus, items};
116
+ }
117
+
118
+ function readItem(node) {
119
+ const meta = {};
120
+ for (const m of children(node, 'wp:postmeta')) {
121
+ const k = textOf(m, 'wp:meta_key');
122
+ if (k && !(k in meta)) meta[k] = textOf(m, 'wp:meta_value', {raw: true});
123
+ }
124
+ const terms = children(node, 'category').map(c => ({
125
+ domain: c.attrs.domain || 'category',
126
+ slug: c.attrs.nicename || '',
127
+ name: c.text.trim()
128
+ })).filter(t => t.slug || t.name);
129
+ const comments = children(node, 'wp:comment').map(c => ({
130
+ id: int(textOf(c, 'wp:comment_id')),
131
+ author: textOf(c, 'wp:comment_author'),
132
+ email: textOf(c, 'wp:comment_author_email'),
133
+ url: textOf(c, 'wp:comment_author_url'),
134
+ date: textOf(c, 'wp:comment_date'),
135
+ dateGmt: textOf(c, 'wp:comment_date_gmt'),
136
+ content: textOf(c, 'wp:comment_content', {raw: true}),
137
+ approved: textOf(c, 'wp:comment_approved'),
138
+ type: textOf(c, 'wp:comment_type'),
139
+ parent: int(textOf(c, 'wp:comment_parent'))
140
+ }));
141
+ return {
142
+ id: int(textOf(node, 'wp:post_id')),
143
+ type: textOf(node, 'wp:post_type') || 'post',
144
+ title: textOf(node, 'title'),
145
+ slug: decodeSlug(textOf(node, 'wp:post_name')),
146
+ status: textOf(node, 'wp:status') || 'publish',
147
+ parent: int(textOf(node, 'wp:post_parent')),
148
+ order: int(textOf(node, 'wp:menu_order')),
149
+ date: textOf(node, 'wp:post_date'),
150
+ dateGmt: textOf(node, 'wp:post_date_gmt'),
151
+ link: textOf(node, 'link'),
152
+ guid: textOf(node, 'guid'),
153
+ creator: textOf(node, 'dc:creator'),
154
+ content: textOf(node, 'content:encoded', {raw: true}),
155
+ excerpt: textOf(node, 'excerpt:encoded', {raw: true}).trim(),
156
+ password: textOf(node, 'wp:post_password'),
157
+ attachmentUrl: textOf(node, 'wp:attachment_url'),
158
+ meta,
159
+ terms,
160
+ comments
161
+ };
162
+ }
163
+
164
+ /** WordPress stores non-ASCII slugs percent-encoded. */
165
+ function decodeSlug(s) {
166
+ try { return decodeURIComponent(s); } catch { return s; }
167
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * A small, strict XML reader for WordPress exports (WXR) - written for safety
3
+ * rather than generality.
4
+ *
5
+ * - No DTDs at all. A `<!DOCTYPE` is refused outright, and so is `<!ENTITY`
6
+ * anywhere: WordPress never writes either, and they are how XXE (external
7
+ * entities reading files or URLs) and "billion laughs" (entities that expand
8
+ * exponentially) get in. Only the five predefined entities and numeric
9
+ * character references are decoded; any other `&name;` is left as text.
10
+ * - Bounded: element depth, attributes per element, name length and the
11
+ * number of nodes all have ceilings, so a hostile file fails fast.
12
+ * - Chunked: `parseXml()` hands the event loop back every few thousand
13
+ * tokens, so a 50 MB export does not freeze the site while it is read.
14
+ * - Namespaces are resolved by URI, not by the prefix a file happens to use:
15
+ * `wp:`, `content:`, `excerpt:` and `dc:` come out under those names
16
+ * whatever the file called them (WXR 1.0, 1.1 and 1.2 differ only in the
17
+ * wp: namespace's version number).
18
+ *
19
+ * The result is a plain tree: {name, attrs, children, text}. Text is the
20
+ * concatenated character data (CDATA included) of the element itself.
21
+ *
22
+ * @module services/wpImport/xml
23
+ */
24
+
25
+ /** Namespace URIs -> the prefix the rest of the importer uses. */
26
+ export function canonicalPrefix(uri) {
27
+ const u = String(uri || '').trim().replace(/\/+$/, '');
28
+ if (/^https?:\/\/wordpress\.org\/export\/1\.\d+\/excerpt$/i.test(u)) return 'excerpt';
29
+ if (/^https?:\/\/wordpress\.org\/export\/1\.\d+$/i.test(u)) return 'wp';
30
+ if (/^https?:\/\/purl\.org\/rss\/1\.0\/modules\/content$/i.test(u)) return 'content';
31
+ if (/^https?:\/\/purl\.org\/dc\/elements\/1\.1$/i.test(u)) return 'dc';
32
+ if (/^https?:\/\/wellformedweb\.org\/CommentAPI$/i.test(u)) return 'wfw';
33
+ return null;
34
+ }
35
+
36
+ export const LIMITS = Object.freeze({
37
+ maxDepth: 64,
38
+ maxAttributes: 64,
39
+ maxNameLength: 256,
40
+ maxNodes: 1_000_000,
41
+ sliceTokens: 4000
42
+ });
43
+
44
+ export class XmlError extends Error {
45
+ constructor(message, at = -1) {
46
+ super(at >= 0 ? `${message} (at character ${at})` : message);
47
+ this.code = 'EXML';
48
+ }
49
+ }
50
+
51
+ const PREDEFINED = {lt: '<', gt: '>', amp: '&', quot: '"', apos: "'"};
52
+
53
+ /** Decode the predefined entities and numeric references - nothing else. */
54
+ export function decodeEntities(s) {
55
+ if (!s || s.indexOf('&') === -1) return s;
56
+ return s.replace(/&(#x[0-9a-f]{1,6}|#[0-9]{1,7}|[a-z]{2,4});/gi, (m, ref) => {
57
+ if (ref[0] === '#') {
58
+ const code = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
59
+ if (!Number.isFinite(code) || code < 1 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return '';
60
+ return String.fromCodePoint(code);
61
+ }
62
+ return Object.prototype.hasOwnProperty.call(PREDEFINED, ref) ? PREDEFINED[ref] : m;
63
+ });
64
+ }
65
+
66
+ const NAME_RE = /^[A-Za-z_][\w.\-]*(:[A-Za-z_][\w.\-]*)?$/;
67
+ const ATTR_RE = /([^\s=/>]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
68
+
69
+ /**
70
+ * Parse an XML document into a tree, yielding to the event loop as it goes.
71
+ *
72
+ * @param {string} text
73
+ * @param {Partial<typeof LIMITS>} [limits]
74
+ * @returns {Promise<{name: string, attrs: object, children: object[], text: string}>} the root element
75
+ * @throws {XmlError}
76
+ */
77
+ export async function parseXml(text, limits = {}) {
78
+ const parser = createParser(String(text ?? ''), {...LIMITS, ...limits});
79
+ while (!parser.done) {
80
+ parser.step();
81
+ if (!parser.done) await new Promise(r => setImmediate(r));
82
+ }
83
+ return parser.root;
84
+ }
85
+
86
+ /** Synchronous form, for small inputs and tests. */
87
+ export function parseXmlSync(text, limits = {}) {
88
+ const parser = createParser(String(text ?? ''), {...LIMITS, ...limits, sliceTokens: Infinity});
89
+ while (!parser.done) parser.step();
90
+ return parser.root;
91
+ }
92
+
93
+ function createParser(src, lim) {
94
+ let i = 0;
95
+ let nodes = 0;
96
+ let root = null;
97
+ // Each frame: {node, ns: Map(prefix -> canonical|null), textParts: []}
98
+ const stack = [];
99
+ const state = {done: false, root: null, step};
100
+ // A leading byte-order mark.
101
+ if (src.charCodeAt(0) === 0xfeff) i = 1;
102
+
103
+ function fail(msg) { throw new XmlError(msg, i); }
104
+
105
+ function canonicalName(raw, ns) {
106
+ const colon = raw.indexOf(':');
107
+ if (colon === -1) return raw;
108
+ const prefix = raw.slice(0, colon);
109
+ const mapped = ns.get(prefix);
110
+ return `${mapped || prefix}:${raw.slice(colon + 1)}`;
111
+ }
112
+
113
+ function addText(t) {
114
+ if (!stack.length) {
115
+ if (/\S/.test(t)) fail('Text outside the document element');
116
+ return;
117
+ }
118
+ stack[stack.length - 1].textParts.push(t);
119
+ }
120
+
121
+ function openTag(body, selfClosing) {
122
+ const m = /^([^\s/>]+)/.exec(body);
123
+ if (!m) fail('An element without a name');
124
+ const rawName = m[1];
125
+ if (rawName.length > lim.maxNameLength || !NAME_RE.test(rawName)) fail(`Not an element name: "${rawName.slice(0, 40)}"`);
126
+ if (stack.length >= lim.maxDepth) fail('Elements are nested too deeply');
127
+ if (root && !stack.length) fail('More than one document element');
128
+ if (++nodes > lim.maxNodes) fail('Too many elements');
129
+ const rest = body.slice(rawName.length);
130
+ const rawAttrs = {};
131
+ let count = 0;
132
+ ATTR_RE.lastIndex = 0;
133
+ let a;
134
+ while ((a = ATTR_RE.exec(rest))) {
135
+ if (++count > lim.maxAttributes) fail('Too many attributes on one element');
136
+ const value = a[3] !== undefined ? a[3] : a[4];
137
+ if (value.indexOf('<') !== -1) fail('A "<" inside an attribute value');
138
+ rawAttrs[a[1]] = decodeEntities(value);
139
+ }
140
+ const parentNs = stack.length ? stack[stack.length - 1].ns : new Map();
141
+ let ns = parentNs;
142
+ for (const [k, v] of Object.entries(rawAttrs)) {
143
+ if (k.startsWith('xmlns:')) {
144
+ if (ns === parentNs) ns = new Map(parentNs);
145
+ ns.set(k.slice(6), canonicalPrefix(v));
146
+ }
147
+ }
148
+ const attrs = {};
149
+ for (const [k, v] of Object.entries(rawAttrs)) {
150
+ if (k === 'xmlns' || k.startsWith('xmlns:')) continue;
151
+ attrs[canonicalName(k, ns)] = v;
152
+ }
153
+ const node = {name: canonicalName(rawName, ns), attrs, children: [], text: ''};
154
+ if (stack.length) stack[stack.length - 1].node.children.push(node);
155
+ else root = node;
156
+ if (selfClosing) return;
157
+ stack.push({node, ns, rawName, textParts: []});
158
+ }
159
+
160
+ function closeTag(rawName) {
161
+ const top = stack.pop();
162
+ if (!top) fail(`A closing </${rawName}> with nothing open`);
163
+ if (top.rawName !== rawName) fail(`</${rawName}> closes <${top.rawName}>`);
164
+ top.node.text = top.textParts.join('');
165
+ }
166
+
167
+ function step() {
168
+ let tokens = 0;
169
+ const n = src.length;
170
+ while (i < n && tokens < lim.sliceTokens) {
171
+ tokens++;
172
+ const lt = src.indexOf('<', i);
173
+ if (lt === -1) {
174
+ addText(decodeEntities(src.slice(i)));
175
+ i = n;
176
+ break;
177
+ }
178
+ if (lt > i) {
179
+ addText(decodeEntities(src.slice(i, lt)));
180
+ i = lt;
181
+ continue;
182
+ }
183
+ // At a '<'.
184
+ if (src.startsWith('<![CDATA[', i)) {
185
+ const end = src.indexOf(']]>', i + 9);
186
+ if (end === -1) fail('A CDATA section that never ends');
187
+ addText(src.slice(i + 9, end));
188
+ i = end + 3;
189
+ } else if (src.startsWith('<!--', i)) {
190
+ const end = src.indexOf('-->', i + 4);
191
+ if (end === -1) fail('A comment that never ends');
192
+ i = end + 3;
193
+ } else if (src.startsWith('<?', i)) {
194
+ const end = src.indexOf('?>', i + 2);
195
+ if (end === -1) fail('A processing instruction that never ends');
196
+ i = end + 2;
197
+ } else if (/^<!DOCTYPE/i.test(src.slice(i, i + 9))) {
198
+ fail('The file declares a DOCTYPE. WordPress exports never do, and a DOCTYPE is how entity attacks get in - refused');
199
+ } else if (/^<!ENTITY/i.test(src.slice(i, i + 8))) {
200
+ fail('The file declares an entity - refused');
201
+ } else if (src[i + 1] === '!') {
202
+ fail('An unexpected "<!" declaration');
203
+ } else if (src[i + 1] === '/') {
204
+ const end = src.indexOf('>', i + 2);
205
+ if (end === -1) fail('A closing tag that never ends');
206
+ closeTag(src.slice(i + 2, end).trim());
207
+ i = end + 1;
208
+ } else {
209
+ const end = findTagEnd(i + 1);
210
+ if (end === -1) fail('A tag that never ends');
211
+ let body = src.slice(i + 1, end);
212
+ const selfClosing = body.endsWith('/');
213
+ if (selfClosing) body = body.slice(0, -1);
214
+ openTag(body, selfClosing);
215
+ i = end + 1;
216
+ }
217
+ }
218
+ if (i >= n) {
219
+ if (stack.length) fail(`<${stack[stack.length - 1].rawName}> is never closed`);
220
+ if (!root) fail('No document element');
221
+ state.done = true;
222
+ state.root = root;
223
+ }
224
+ }
225
+
226
+ /** The '>' that ends a start tag, skipping any inside quoted attribute values. */
227
+ function findTagEnd(from) {
228
+ let q = '';
229
+ for (let j = from; j < src.length; j++) {
230
+ const c = src[j];
231
+ if (q) { if (c === q) q = ''; continue; }
232
+ if (c === '"' || c === "'") q = c;
233
+ else if (c === '>') return j;
234
+ else if (c === '<') fail('A "<" inside a tag');
235
+ }
236
+ return -1;
237
+ }
238
+
239
+ return state;
240
+ }
241
+
242
+ // ---- Reading the tree ----------------------------------------------------
243
+
244
+ /** The first child element called `name`, or null. */
245
+ export function child(node, name) {
246
+ if (!node) return null;
247
+ for (const c of node.children) if (c.name === name) return c;
248
+ return null;
249
+ }
250
+
251
+ /** Every child element called `name`. */
252
+ export function children(node, name) {
253
+ return node ? node.children.filter(c => c.name === name) : [];
254
+ }
255
+
256
+ /** The text of the first child called `name` ('' when absent), trimmed unless `raw`. */
257
+ export function textOf(node, name, {raw = false} = {}) {
258
+ const c = child(node, name);
259
+ if (!c) return '';
260
+ return raw ? c.text : c.text.trim();
261
+ }