@bygghammar/cms-platform 0.92.0 → 0.92.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,29 @@ matches `package.json` exactly. See
8
8
  Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
9
9
  from the published npm tarballs.
10
10
 
11
+ ## 0.92.2
12
+
13
+ - Fix rich-text attribute escaping. `sanitizeHtml` read attribute values as
14
+ raw text and escaped them again, so every save and every render added an
15
+ `amp;` to each `&` in a link: `?calendardates=…&trp=…` reached the browser
16
+ with a query key named `amp;trp`. Attribute values are now decoded once,
17
+ checked, and escaped exactly once, so sanitizing is idempotent through save
18
+ and render. The URL allowlist, style checks and target check judge the
19
+ decoded value, the one the browser will use. Text content is unchanged.
20
+ - Links stored before this release keep their extra levels until an editor
21
+ saves the correct URL; they no longer grow on each save.
22
+ - Two scheduling test suites no longer depend on the date they run on.
23
+
24
+ ## 0.92.1
25
+
26
+ - Fix the page-builder save and publish RPC call by removing the unsupported
27
+ `_source_page_id` argument. The database supplies the source page internally,
28
+ preserving self-reveal protection. No migration is added or changed, and
29
+ platform contract v3 is unchanged.
30
+ - Clarify publication RPC errors so an argument mismatch is not presented as
31
+ proof that migrations must be replayed. Include only a recognized diagnostic
32
+ code and request a schema comparison before applying changes.
33
+
11
34
  ## 0.92.0
12
35
 
13
36
  **Breaking: platform contract v3.** A site pinned to this package must call
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.92.0",
3
+ "version": "0.92.2",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -85,6 +85,48 @@ function escapeAttr(v: string): string {
85
85
  return v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
86
86
  }
87
87
 
88
+ /** The named references the editor and browsers write into attribute values. */
89
+ const NAMED_REFERENCES: Record<string, string> = {
90
+ amp: "&",
91
+ AMP: "&",
92
+ lt: "<",
93
+ LT: "<",
94
+ gt: ">",
95
+ GT: ">",
96
+ quot: '"',
97
+ QUOT: '"',
98
+ apos: "'",
99
+ nbsp: "\u00a0",
100
+ };
101
+
102
+ /**
103
+ * An attribute value as a browser reads it: its character references decoded.
104
+ *
105
+ * Stored markup is HTML, so `?a=1&amp;b=2` means `?a=1&b=2`. Everything below
106
+ * judges the decoded value, and `escapeAttr` encodes it exactly once on the
107
+ * way out. Reading the raw text instead let `escapeAttr` encode the editor's
108
+ * own `&amp;` a second time, so every save and every render added a level and
109
+ * a link's `&b=` arrived in the browser as a key named `amp;b`.
110
+ *
111
+ * Numeric references, and the named ones above, are decoded. Any other name
112
+ * is left as written, so it is escaped as text and reaches the browser as that
113
+ * literal text. Either way the browser ends up with exactly the string that
114
+ * was checked: `&#106;avascript:` is judged as `javascript:`, and an
115
+ * undecoded `javascript&colon;` never becomes one.
116
+ */
117
+ function decodeAttr(value: string): string {
118
+ return value.replace(
119
+ /&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{0,31}));/g,
120
+ (reference, decimal?: string, hex?: string, name?: string) => {
121
+ if (name !== undefined) return NAMED_REFERENCES[name] ?? reference;
122
+ const code = decimal !== undefined ? Number.parseInt(decimal, 10) : Number.parseInt(hex ?? "", 16);
123
+ // What a browser does with these: the replacement character.
124
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return "\ufffd";
125
+ return String.fromCodePoint(code);
126
+ },
127
+ );
128
+ }
129
+
88
130
  const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
89
131
 
90
132
  function sanitizeAttrs(tag: string, raw: string): string {
@@ -94,7 +136,7 @@ function sanitizeAttrs(tag: string, raw: string): string {
94
136
  ATTR_RE.lastIndex = 0;
95
137
  while ((m = ATTR_RE.exec(raw))) {
96
138
  const name = m[1].toLowerCase();
97
- let value = m[3] ?? m[4] ?? m[5] ?? "";
139
+ let value = decodeAttr(m[3] ?? m[4] ?? m[5] ?? "");
98
140
  if (name.startsWith("on")) continue;
99
141
  if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
100
142
  if (name === "href" || name === "src" || name === "srcset") {
@@ -377,9 +377,9 @@ export async function getPageForEditCore({ context }: PagesDeps, key: string) {
377
377
  }
378
378
 
379
379
  /**
380
- * Codes and wording that mean "this installation has not run the publication
381
- * migration yet". `PGRST202`/`PGRST203` come from PostgREST, `42883`/`42P01`
382
- * from PostgreSQL itself.
380
+ * Codes and wording that mean "this installation has no such function, or none
381
+ * that takes these arguments". `PGRST202`/`PGRST203` come from PostgREST, which
382
+ * answers the same way for both; `42883`/`42P01` from PostgreSQL itself.
383
383
  */
384
384
  const FUNCTION_MISSING = new Set(["PGRST202", "PGRST203", "42883", "42P01"]);
385
385
 
@@ -423,10 +423,14 @@ async function publicationRpc(
423
423
  ) {
424
424
  // A slice of the generated migration manifest rather than a second copy of
425
425
  // the names: an operator sent after a file this package has stopped
426
- // shipping is worse off than one told nothing.
426
+ // shipping is worse off than one told nothing. Only a code from the set
427
+ // above is repeated; the backend's own message is not.
427
428
  throw new Error(
428
- `${whenFailed}: this database does not have ${fn}. Apply the migrations ` +
429
- `${installedBy.join(" and ")} shipped with this package and try again.`,
429
+ `${whenFailed}: the database has no ${fn} that accepts this call` +
430
+ `${FUNCTION_MISSING.has(code) ? ` (${code})` : ""}. Either the migrations ` +
431
+ `${installedBy.join(" and ")} shipped with this package are not installed, ` +
432
+ `or the installed ${fn} takes different arguments than this package sends. ` +
433
+ `Compare the database's schema with this package before applying anything.`,
430
434
  );
431
435
  }
432
436
  throw new Error(message || whenFailed);
@@ -474,10 +478,10 @@ export async function savePageVersionCore(
474
478
  _note: input.note ?? null,
475
479
  _publish: Boolean(input.publish),
476
480
  _actor_email: actorEmail(context),
481
+ // No `_source_page_id`: the function takes none, and names `_page_id` as
482
+ // the source of every reveal itself, so a countdown pointing at the page
483
+ // being published is still refused rather than hiding it.
477
484
  _reveals: reveals,
478
- // Named so a countdown pointing at the page being published is refused
479
- // rather than hiding it until its own deadline.
480
- _source_page_id: input.pageId,
481
485
  _audit: input.publish
482
486
  ? {
483
487
  description: