| @@ -1,23 +1,141 @@ | ||
| 1 | 1 | import { trimTrailingChar } from './text' |
| 2 | -import type { AxiosRequestConfig } from 'axios' | |
| 2 | +import { buildUrl } from './urls' | |
| 3 | +import type { UrlQueryArgs } from './urls' | |
| 4 | +import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios' | |
| 3 | 5 | |
| 4 | 6 | const normalizeUrl = (url: string | undefined) => |
| 5 | 7 | trimTrailingChar(url ?? '', '/') |
| 6 | 8 | |
| 7 | 9 | export const REST_BASES = { |
| 10 | + base: normalizeUrl(window.CODE_SNIPPETS?.restAPI.base), | |
| 8 | 11 | snippets: normalizeUrl(window.CODE_SNIPPETS?.restAPI.snippets), |
| 9 | 12 | recentlyActive: normalizeUrl(window.CODE_SNIPPETS?.restAPI.recentlyActive), |
| 10 | - preferences: normalizeUrl(window.CODE_SNIPPETS?.restAPI.preferences), | |
| 11 | - importPlugins: normalizeUrl(window.CODE_SNIPPETS?.restAPI.importPlugins), | |
| 12 | - importFiles: normalizeUrl(window.CODE_SNIPPETS?.restAPI.importFiles), | |
| 13 | + import: { | |
| 14 | + plugins: normalizeUrl(window.CODE_SNIPPETS?.restAPI.importPlugins), | |
| 15 | + files: normalizeUrl(window.CODE_SNIPPETS?.restAPI.importFiles), | |
| 16 | + }, | |
| 17 | + preferences: { | |
| 18 | + snippetView: normalizeUrl(window.CODE_SNIPPETS?.restAPI.snippetView), | |
| 19 | + insights: normalizeUrl(window.CODE_SNIPPETS?.restAPI.insightsView), | |
| 20 | + demosSeen: normalizeUrl(window.CODE_SNIPPETS?.restAPI.demosSeen), | |
| 21 | + }, | |
| 13 | 22 | cloud: { |
| 14 | 23 | snippets: normalizeUrl(window.CODE_SNIPPETS?.restAPI.cloud.snippets), |
| 15 | 24 | } |
| 16 | 25 | } |
| 17 | 26 | |
| 27 | +/** Verbs that hosts and firewalls commonly reject outright. */ | |
| 28 | +const OVERRIDDEN_METHODS = ['delete', 'put', 'patch'] | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * Send write requests as POST, naming the intended verb in a header. | |
| 32 | + * | |
| 33 | + * Plenty of hosts allow only GET and POST, so a DELETE never reaches | |
| 34 | + * WordPress: the request is rejected upstream, and the browser reports a 403 — | |
| 35 | + * or a severed connection — that no amount of correct authentication can fix. | |
| 36 | + * The REST server reads `X-HTTP-Method-Override` on a POST and dispatches the | |
| 37 | + * route exactly as it would have, so this changes nothing WordPress sees while | |
| 38 | + * letting the request through. | |
| 39 | + */ | |
| 40 | +export const applyMethodOverride = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { | |
| 41 | + const method = config.method?.toLowerCase() | |
| 42 | + | |
| 43 | + if (!method || !OVERRIDDEN_METHODS.includes(method)) { | |
| 44 | + return config | |
| 45 | + } | |
| 46 | + | |
| 47 | + config.headers.set('X-HTTP-Method-Override', method.toUpperCase()) | |
| 48 | + config.method = 'post' | |
| 49 | + | |
| 50 | + return config | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** | |
| 54 | + * The REST nonce to authenticate the next request with. | |
| 55 | + * | |
| 56 | + * Held in a variable rather than baked into the axios config, because the value | |
| 57 | + * the page was rendered with does not stay valid. A nonce expires with the | |
| 58 | + * session, and the snippet editor is a screen people leave open for a long | |
| 59 | + * time. Once it lapsed, every save failed with a 403 and the only cure was | |
| 60 | + * reloading the page, which loses whatever was being written. | |
| 61 | + * | |
| 62 | + * The feedback reporter mounts on screens that do not enqueue the main | |
| 63 | + * `CODE_SNIPPETS` object, so it carries a nonce of its own to fall back on. | |
| 64 | + */ | |
| 65 | +let restNonce = window.CODE_SNIPPETS?.restAPI.nonce ?? window.CODE_SNIPPETS_FEEDBACK?.nonce | |
| 66 | +let runOnceNonce = window.CODE_SNIPPETS_MANAGE?.runOnceNonce | |
| 67 | + | |
| 68 | +/** The Run Once nonce as last refreshed by the Heartbeat, or the one rendered with the page. */ | |
| 69 | +export const getRunOnceNonce = (): string => runOnceNonce ?? '' | |
| 70 | + | |
| 71 | +/** | |
| 72 | + * Keep the REST nonce current for as long as the page is open. | |
| 73 | + * | |
| 74 | + * WordPress already sends a freshly minted nonce with every Heartbeat response, | |
| 75 | + * from `wp_refresh_heartbeat_nonces()`. Core applies it to `wpApiSettings`, | |
| 76 | + * which our screens do not enqueue, so the value went unused. Listening for the | |
| 77 | + * tick ourselves means an editor left open stays able to save. | |
| 78 | + */ | |
| 79 | +export const listenForNonceRefresh = () => { | |
| 80 | + // Heartbeat also fires the tick through the hooks API, which avoids | |
| 81 | + // depending on jQuery being present and typed. | |
| 82 | + window.wp.hooks?.addAction( | |
| 83 | + 'heartbeat.tick', | |
| 84 | + 'code-snippets/refresh-rest-nonce', | |
| 85 | + (data: { rest_nonce?: string, code_snippets_run_once_nonce?: string }) => { | |
| 86 | + if (data.rest_nonce) { | |
| 87 | + restNonce = data.rest_nonce | |
| 88 | + } | |
| 89 | + | |
| 90 | + if (data.code_snippets_run_once_nonce) { | |
| 91 | + runOnceNonce = data.code_snippets_run_once_nonce | |
| 92 | + } | |
| 93 | + } | |
| 94 | + ) | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** | |
| 98 | + * Attach the current nonce to an outgoing request. | |
| 99 | + * | |
| 100 | + * Read per request, so that a nonce refreshed since page load is actually used. | |
| 101 | + */ | |
| 102 | +export const applyRestNonce = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { | |
| 103 | + if (restNonce) { | |
| 104 | + config.headers.set('X-WP-Nonce', restNonce) | |
| 105 | + } | |
| 106 | + | |
| 107 | + return config | |
| 108 | +} | |
| 109 | + | |
| 18 | 110 | export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = { |
| 19 | 111 | headers: { |
| 20 | - 'X-WP-Nonce': window.CODE_SNIPPETS?.restAPI.nonce, | |
| 21 | 112 | 'Access-Control': window.CODE_SNIPPETS?.restAPI.cloud.token |
| 22 | 113 | } |
| 23 | 114 | } |
| 115 | + | |
| 116 | +export interface QueryArg { | |
| 117 | + url: string | |
| 118 | + name: string | |
| 119 | + value: string | |
| 120 | +} | |
| 121 | + | |
| 122 | +/** | |
| 123 | + * Add a query parameter to a REST URL. | |
| 124 | + * | |
| 125 | + * Concatenation is not enough: with plain permalinks a REST URL already carries the route | |
| 126 | + * in a query string, so a second `?` would bury the parameter inside the route instead of | |
| 127 | + * adding one. | |
| 128 | + */ | |
| 129 | +export const addQueryArg = ({ url, name, value }: QueryArg): string => { | |
| 130 | + const parsed = new URL(url, window.location.origin) | |
| 131 | + parsed.searchParams.set(name, value) | |
| 132 | + return parsed.toString() | |
| 133 | +} | |
| 134 | + | |
| 135 | +/** | |
| 136 | + * A WordPress core REST route (`wp/v2/…`) with its query arguments, built so it | |
| 137 | + * works whether or not the site has pretty permalinks: without them the REST | |
| 138 | + * base already carries a query string, so arguments must be appended with `&`. | |
| 139 | + */ | |
| 140 | +export const buildWpRestUrl = (route: string, args: UrlQueryArgs = {}): string => | |
| 141 | + buildUrl(`${REST_BASES.base}/wp/v2/${route}`, args) | |