import { ProjectUIDL } from '@teleporthq/teleport-types'
import {
RESET_TOKEN_STORAGE_KEY,
readNewsletterSettings,
resetTokenKeeperPagePaths,
} from '@teleporthq/teleport-plugin-next-workflows'
/**
* The address-key keeper: the first script of every page's `
` (the first
* global asset), ahead of the owner's scripts and custom head code. A link
* that IS a credential — whoever holds it acts as its owner — must leave the
* address before any other script of the page can read it: an analytics or ads
* tag the owner added reads `location.href`, and the address travels on in a
* Referer. Two links carry one:
*
* - A customer's booking link carries the booking's manage key: in its query
* (`/booking/BK-…?key=…` or `/booking?ref=BK-…&key=…`) on every link
* already emailed, in its fragment (`/booking/BK-…#key=…`) on the address the
* payment provider sends the customer back to (teleport-gui
* `buildManageUrlScript`; a fragment never reaches a server or a Referer).
* On a page whose address names a booking reference (`BK-` and at least six
* letters or digits, in `ref` or as the last path segment) and a `key`, the
* keeper keeps the key in the tab's sessionStorage under
* `tq-booking-key:` — so a customer who returns in another tab or
* browser still reaches the booking. The booking page's step that reads the
* link takes it from there (teleport-gui's `EXTRACT_MANAGE_LINK_SCRIPT`).
* - A password-reset link carries its token: in its fragment (`#token=`) on
* every link sent now, in its query (`?token=`) on links already sent. On
* the reset page (the path the reset email links to, under a locale prefix
* too) the keeper keeps it under `RESET_TOKEN_STORAGE_KEY`; the reset page's
* steps read it from there (teleport-gui `READ_RESET_TOKEN_SCRIPT`).
* - A newsletter link carries its token: in its fragment (`#t=`) on every
* link sent now, in its query (`?t=`) on links already sent; the token
* carries the subscriber's address. On the newsletter's confirm and unsubscribe pages
* (the paths the emails link to, under a locale prefix too) the keeper keeps
* it under `NEWSLETTER_TOKEN_STORAGE_KEY`; the pages' steps read it from
* there (teleport-gui `readNewsletterTokenScript`).
*
* In every case the address is replaced with the same one without the key
* (`history.replaceState`). Where storage refuses the write (a locked-down
* browser), the address is left as it was and the page reads the key from it
* as before.
*
* Each rule ships only to a site whose workflows read what it keeps (they name
* its storage key): a site exported by an editor that still reads the key from
* the address keeps working exactly as it did.
*/
/** The sessionStorage key prefix the editor's booking scripts read (mirrored in teleport-gui). */
export const BOOKING_KEY_STORAGE_PREFIX = 'tq-booking-key:'
/** The sessionStorage key the newsletter pages read a link's token from (mirrored in teleport-gui). */
export const NEWSLETTER_TOKEN_STORAGE_KEY = 'tq-newsletter-token'
const BOOKING_KEEPER_MARKER = '/* tq-booking-key-keeper */'
const RESET_KEEPER_MARKER = '/* tq-reset-token-keeper */'
const NEWSLETTER_KEEPER_MARKER = '/* tq-newsletter-token-keeper */'
export interface AddressKeyKeeperRules {
/** Keep a booking link's manage key (`key`). */
bookingKeys?: boolean
/** Keep a reset link's token (`token`) on these pages. */
resetPagePaths?: string[]
/** Keep a newsletter link's token (`t`) on these pages. */
newsletterPagePaths?: string[]
}
const BOOKING_RULE_LINES = [
' var key = String(query.get("key") || fragment.get("key") || "").trim().toLowerCase();',
' if (key) {',
' var pattern = new RegExp("^BK-[A-Za-z0-9]{6,}$");',
' var reference = String(query.get("ref") || "").trim();',
' if (!reference) {',
' var segments = String(here.pathname || "").split("/").filter(function (s) { return s.length > 0; });',
' var last = segments.length > 0 ? segments[segments.length - 1] : "";',
' try { last = decodeURIComponent(last); } catch (e) { /* as is */ }',
' reference = last;',
' }',
' reference = reference.toUpperCase();',
' if (pattern.test(reference)) {',
` window.sessionStorage.setItem(${JSON.stringify(
BOOKING_KEY_STORAGE_PREFIX
)} + reference, key);`,
' taken.push("key");',
' }',
' }',
]
const resetRuleLines = (resetPagePaths: string[]): string[] => [
` var resetPages = ${JSON.stringify(resetPagePaths)};`,
' var path = String(here.pathname || "");',
' if (path.length > 1) { path = path.replace(new RegExp("/+$"), ""); }',
// The page itself, or the page under one locale segment (`/fr/reset-password`).
' var onResetPage = resetPages.some(function (page) {',
' if (path === page) { return true; }',
' if (page === "/" || path.length <= page.length) { return false; }',
' var prefix = path.slice(0, path.length - page.length);',
' return path.slice(path.length - page.length) === page && prefix.split("/").length === 2;',
' });',
' var token = String(fragment.get("token") || query.get("token") || "").trim();',
' if (onResetPage && token) {',
` window.sessionStorage.setItem(${JSON.stringify(RESET_TOKEN_STORAGE_KEY)}, token);`,
' taken.push("token");',
' }',
]
// Same page match as the reset rule's, on its own names.
const newsletterRuleLines = (newsletterPagePaths: string[]): string[] => [
` var newsletterPages = ${JSON.stringify(newsletterPagePaths)};`,
' var newsletterPath = String(here.pathname || "");',
' if (newsletterPath.length > 1) { newsletterPath = newsletterPath.replace(new RegExp("/+$"), ""); }',
' var onNewsletterPage = newsletterPages.some(function (page) {',
' if (newsletterPath === page) { return true; }',
' if (page === "/" || newsletterPath.length <= page.length) { return false; }',
' var prefix = newsletterPath.slice(0, newsletterPath.length - page.length);',
' return newsletterPath.slice(newsletterPath.length - page.length) === page && prefix.split("/").length === 2;',
' });',
' var newsletterToken = String(fragment.get("t") || query.get("t") || "").trim();',
' if (onNewsletterPage && newsletterToken) {',
` window.sessionStorage.setItem(${JSON.stringify(
NEWSLETTER_TOKEN_STORAGE_KEY
)}, newsletterToken);`,
' taken.push("t");',
' }',
]
/** Authored as source text with no backslash: it is emitted verbatim. */
export const generateAddressKeyKeeperSource = (rules: AddressKeyKeeperRules): string => {
const resetPagePaths = rules.resetPagePaths || []
const newsletterPagePaths = rules.newsletterPagePaths || []
return [
...(rules.bookingKeys ? [BOOKING_KEEPER_MARKER] : []),
...(resetPagePaths.length > 0 ? [RESET_KEEPER_MARKER] : []),
...(newsletterPagePaths.length > 0 ? [NEWSLETTER_KEEPER_MARKER] : []),
'(function () {',
' try {',
' var here = window.location;',
' var query = new URLSearchParams(here.search || "");',
' var fragment = new URLSearchParams(String(here.hash || "").slice(1));',
' var taken = [];',
...(rules.bookingKeys ? BOOKING_RULE_LINES : []),
...(resetPagePaths.length > 0 ? resetRuleLines(resetPagePaths) : []),
...(newsletterPagePaths.length > 0 ? newsletterRuleLines(newsletterPagePaths) : []),
' if (taken.length === 0) { return; }',
' var inFragment = taken.some(function (name) { return fragment.has(name); });',
' taken.forEach(function (name) { query.delete(name); fragment.delete(name); });',
' var rest = query.toString();',
' var restFragment = inFragment ? fragment.toString() : String(here.hash || "").slice(1);',
' window.history.replaceState(window.history.state, "", here.pathname + (rest ? "?" + rest : "") + (restFragment ? "#" + restFragment : ""));',
' } catch (e) {',
' /* storage or history refused: the address keeps the key, the page reads it there */',
' }',
'})();',
].join('\n')
}
/** A site with Bookings whose booking pages read the key the keeper keeps. */
export const shouldKeepBookingKeysOutOfTheAddress = (uidl: ProjectUIDL): boolean => {
if (!uidl.bookingSettings || !uidl.workflows) {
return false
}
try {
return JSON.stringify(uidl.workflows).includes(BOOKING_KEY_STORAGE_PREFIX)
} catch (error) {
return false
}
}
/** A same-site page path the emails link to (never an API route), else null. */
const newsletterPagePathOf = (value: unknown): string | null => {
const path = typeof value === 'string' ? value.trim() : ''
if (
!path.startsWith('/') ||
path.startsWith('//') ||
path.startsWith('/api/') ||
path.indexOf('..') !== -1 ||
/[?#\s"'<>\\]/.test(path)
) {
return null
}
return path.length > 1 ? path.replace(/\/+$/, '') : null
}
/**
* The newsletter pages the keeper watches: the confirm and unsubscribe pages
* the emails link to, on a site whose workflows read the token it keeps (they
* name NEWSLETTER_TOKEN_STORAGE_KEY) — a site exported before reads `?t=` as
* it did.
*/
export const newsletterTokenKeeperPagePaths = (uidl: ProjectUIDL): string[] => {
const settings = readNewsletterSettings(uidl)
if (!settings || !uidl.workflows) {
return []
}
try {
if (!JSON.stringify(uidl.workflows).includes(NEWSLETTER_TOKEN_STORAGE_KEY)) {
return []
}
} catch (error) {
return []
}
const pages = settings.pages || { confirm: '', unsubscribe: '' }
const paths = [newsletterPagePathOf(pages.confirm), newsletterPagePathOf(pages.unsubscribe)]
return paths.filter((path, index): path is string => !!path && paths.indexOf(path) === index)
}
const isKeeperAsset = (asset: unknown): boolean => {
const content =
asset && typeof asset === 'object' ? (asset as { content?: unknown }).content : null
return (
typeof content === 'string' &&
(content.startsWith(BOOKING_KEEPER_MARKER) ||
content.startsWith(RESET_KEEPER_MARKER) ||
content.startsWith(NEWSLETTER_KEEPER_MARKER))
)
}
/**
* Makes the keeper of the project's rules the first global asset: the first
* script of ``. Idempotent; a project neither rule applies to is left
* as it was.
*/
export const injectAddressKeyKeeper = (uidl: ProjectUIDL): void => {
const rules: AddressKeyKeeperRules = {
bookingKeys: shouldKeepBookingKeysOutOfTheAddress(uidl),
resetPagePaths: resetTokenKeeperPagePaths(uidl),
newsletterPagePaths: newsletterTokenKeeperPagePaths(uidl),
}
if (
!rules.bookingKeys &&
rules.resetPagePaths.length === 0 &&
rules.newsletterPagePaths.length === 0
) {
return
}
const assets = (uidl.globals.assets || []).filter((asset) => !isKeeperAsset(asset))
uidl.globals.assets = [
{ type: 'script', content: generateAddressKeyKeeperSource(rules) },
...assets,
]
}