@druxt-contrib/node-preview 0.1.0-dev.20261030024907 → 0.1.0-dev.20261109195239

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.
@@ -14,8 +14,57 @@
14
14
  export default ({ app, store }, inject) => {
15
15
  const include = JSON.parse('<%= JSON.stringify(options.include) %>')
16
16
  const layouts = JSON.parse('<%= JSON.stringify(options.layouts) %>')
17
- // Origins the preview may come from, besides this site's own.
17
+ // Origins the preview may come from, besides this site's own. The module
18
+ // only passes origins with plain host names, so none can end the string.
18
19
  const origins = JSON.parse('<%= JSON.stringify(options.origins) %>')
20
+ // Druxt's Drupal origin. The page fetches from any other origin without the
21
+ // Druxt client, whose options are for druxt.baseUrl.
22
+ const drupal = JSON.parse('<%= JSON.stringify(options.drupal) %>')
23
+
24
+ // Separates the endpoint from a token in the URL fragment.
25
+ const TOKEN_MARK = ';token='
26
+ // The token, once read and taken out of the address, and its endpoint.
27
+ let kept = null
28
+ // vue-router normalises the URL, which collapses the double slash after the
29
+ // scheme inside the fragment. This repairs it.
30
+ const repair = (endpoint) =>
31
+ String(endpoint).replace(/^(https?):\/(?!\/)/, '$1://')
32
+
33
+ /**
34
+ * Returns the endpoint and the token from a raw URL fragment.
35
+ *
36
+ * It throws on a fragment that doesn't decode, such as "#section%".
37
+ *
38
+ * @param {string} raw - The fragment, without the "#".
39
+ *
40
+ * @returns {string[]} The endpoint, then the token if there is one.
41
+ */
42
+ const fragment = (raw) => decodeURIComponent(raw).split(TOKEN_MARK)
43
+
44
+ // Where the tab keeps the token for a reload.
45
+ const TOKEN_STORE = 'druxtNodePreview.token'
46
+
47
+ /**
48
+ * Whether a token is a JWT whose expiry has passed.
49
+ *
50
+ * A token without a readable expiry counts as live, and Drupal decides.
51
+ *
52
+ * @param {string} token - The token.
53
+ *
54
+ * @returns {boolean} True when it has expired.
55
+ */
56
+ const expired = (token) => {
57
+ try {
58
+ const part = String(token)
59
+ .split('.')[1]
60
+ .replace(/-/g, '+')
61
+ .replace(/_/g, '/')
62
+ const { exp } = JSON.parse(window.atob(part))
63
+ return typeof exp === 'number' && exp * 1000 <= Date.now()
64
+ } catch {
65
+ return false
66
+ }
67
+ }
19
68
 
20
69
  // Drupal gives every JSON:API resource a UUID, and a JSON:API Node Preview
21
70
  // URL ends in /{uuid}/preview.
@@ -219,14 +268,14 @@ export default ({ app, store }, inject) => {
219
268
  *
220
269
  * @returns {Error} The error.
221
270
  */
222
- const failure = (url, status, body) =>
223
- new Error(
224
- 'Preview request failed with HTTP ' +
225
- status +
226
- ' for ' +
227
- url +
228
- (body ? ': ' + String(body).slice(0, 300) : '')
271
+ const failure = (url, status, body) => {
272
+ // An HTML error page says nothing useful in a one-line message.
273
+ const text = String(body || '').trim()
274
+ const shown = text && !text.startsWith('<') ? ': ' + text.slice(0, 300) : ''
275
+ return new Error(
276
+ 'Preview request failed with HTTP ' + status + ' for ' + url + shown
229
277
  )
278
+ }
230
279
 
231
280
  const plugin = {
232
281
  /**
@@ -253,18 +302,102 @@ export default ({ app, store }, inject) => {
253
302
  endpoint: () => {
254
303
  const hash = (window.location.hash || '').slice(1)
255
304
  if (!hash) return null
256
- // vue-router normalises the URL, which collapses the double slash after
257
- // the scheme inside the fragment. Repair it.
258
- return decodeURIComponent(hash).replace(/^(https?):\/(?!\/)/, '$1://')
305
+ try {
306
+ return repair(fragment(hash)[0])
307
+ } catch {
308
+ // A fragment that doesn't decode, such as "#section%", has no
309
+ // endpoint, so the page says so.
310
+ return null
311
+ }
312
+ },
313
+
314
+ /**
315
+ * Returns the token from the URL fragment, for a frontend on another site.
316
+ *
317
+ * @returns {string|null} The token, or null when the preview reads with
318
+ * the editor's session.
319
+ */
320
+ token: () => {
321
+ const raw = (window.location.hash || '').slice(1)
322
+ let endpoint
323
+ let token
324
+ try {
325
+ ;[endpoint, token] = fragment(raw)
326
+ } catch {
327
+ // A fragment that is not a preview, such as "#section%", has no token.
328
+ return null
329
+ }
330
+ if (token) {
331
+ // An expired token would fail where the session might not, so the
332
+ // page leaves it out. It still leaves the address.
333
+ const live = !expired(token)
334
+ if (live) {
335
+ kept = { endpoint, token }
336
+ // A reload starts a new page from the address without the token.
337
+ // The tab's session storage keeps it for that, out of any logged
338
+ // address.
339
+ try {
340
+ window.sessionStorage.setItem(TOKEN_STORE, JSON.stringify(kept))
341
+ } catch {
342
+ // Storage can be off. The page then reads the token only once.
343
+ }
344
+ }
345
+ // An error tracker or session recorder on the page can log the
346
+ // address. So the token leaves it, and the page keeps it here.
347
+ // The address keeps the endpoint as it was encoded, so it reads the
348
+ // same when the page loads again.
349
+ const at = raw.indexOf(TOKEN_MARK)
350
+ const address =
351
+ at === -1 ? encodeURIComponent(endpoint) : raw.slice(0, at)
352
+ window.history.replaceState(
353
+ window.history.state,
354
+ '',
355
+ window.location.pathname + window.location.search + '#' + address
356
+ )
357
+ return live ? token : null
358
+ }
359
+ if (!kept) {
360
+ try {
361
+ kept = JSON.parse(window.sessionStorage.getItem(TOKEN_STORE))
362
+ } catch {
363
+ kept = null
364
+ }
365
+ // Use only what this plugin stored: an endpoint and a token.
366
+ if (
367
+ !kept ||
368
+ typeof kept.endpoint !== 'string' ||
369
+ typeof kept.token !== 'string'
370
+ ) {
371
+ kept = null
372
+ }
373
+ }
374
+ // A kept token that has expired would fail where the session might
375
+ // not, so the page drops it.
376
+ if (kept && expired(kept.token)) {
377
+ kept = null
378
+ try {
379
+ window.sessionStorage.removeItem(TOKEN_STORE)
380
+ } catch {
381
+ // Storage can be off.
382
+ }
383
+ }
384
+ return kept && repair(kept.endpoint) === repair(endpoint)
385
+ ? kept.token
386
+ : null
259
387
  },
260
388
 
261
389
  /**
262
- * Fetches the preview document with the editor's session.
390
+ * Fetches the preview document with the editor's session, or with the
391
+ * fragment's token where the session can't reach this frontend. A token
392
+ * goes without credentials, so CORS on Drupal stays as it is.
263
393
  *
264
394
  * The request goes through the Druxt client, with credentials. The site's
265
395
  * request interceptors apply, and the editor's session cookie goes with
266
396
  * it. A path stays a path. The client resolves it against its own base
267
- * URL, and interceptors that decide by path still see it.
397
+ * URL, and interceptors that decide by path still see it. Only the
398
+ * page's own origin and druxt.baseUrl's use the client, because its
399
+ * options are for druxt.baseUrl. Any other origin, such as a register
400
+ * backend or one in the origins option, gets a plain request.
268
401
  *
269
402
  * @param {string} endpoint - The JSON:API Node Preview endpoint.
270
403
  *
@@ -273,19 +406,32 @@ export default ({ app, store }, inject) => {
273
406
  fetch: async (endpoint) => {
274
407
  const uuid = checkEndpoint(endpoint)
275
408
  const url = withInclude(endpoint)
276
- const headers = { Accept: 'application/vnd.api+json' }
277
- const client = (app.$druxt || {}).axios
409
+ const token = plugin.token()
410
+ const headers = {
411
+ Accept: 'application/vnd.api+json',
412
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
413
+ }
414
+ // The Druxt client's defaults, such as its auth, are for druxt.baseUrl.
415
+ // Any other origin gets a plain request instead.
416
+ const { origin } = new URL(url, window.location.origin)
417
+ const own = origin === window.location.origin || origin === drupal
418
+ const client = own ? (app.$druxt || {}).axios : null
278
419
  if (client) {
279
420
  let response
280
421
  try {
281
- response = await client.get(url, { headers, withCredentials: true })
422
+ response = await client.get(url, {
423
+ headers,
424
+ withCredentials: !token,
425
+ })
282
426
  } catch (error) {
283
427
  const failed = error.response || {}
284
428
  if (!failed.status) throw error
285
429
  const body =
286
- typeof failed.data === 'string'
287
- ? failed.data
288
- : JSON.stringify(failed.data || '')
430
+ failed.data === undefined || failed.data === null
431
+ ? ''
432
+ : typeof failed.data === 'string'
433
+ ? failed.data
434
+ : JSON.stringify(failed.data)
289
435
  throw failure(url, failed.status, body)
290
436
  }
291
437
  checkType((response.headers || {})['content-type'])
@@ -293,7 +439,7 @@ export default ({ app, store }, inject) => {
293
439
  return response.data
294
440
  }
295
441
  const response = await window.fetch(url, {
296
- credentials: 'include',
442
+ credentials: token ? 'omit' : 'include',
297
443
  headers,
298
444
  })
299
445
  if (!response.ok) {
@@ -341,5 +487,16 @@ export default ({ app, store }, inject) => {
341
487
  },
342
488
  }
343
489
 
490
+ // Take the token out of the address before anything on the page reads it,
491
+ // such as an analytics page view. The plugin keeps it for the preview. The
492
+ // router's first navigation writes back the address it started with, so
493
+ // take it out again once the router is ready. A script that runs before
494
+ // this plugin, such as one in the page head or an earlier plugin, can still
495
+ // read the token.
496
+ if (typeof window !== 'undefined') {
497
+ plugin.token()
498
+ if (app.router && app.router.onReady) app.router.onReady(plugin.token)
499
+ }
500
+
344
501
  inject('druxtNodePreview', plugin)
345
502
  }