PluginProbe
Leadpages / trunk
Leadpages vtrunk
1.3.0 1.2.1 1.2.2 1.2.3 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0
leadpages / includes / Proxy.php

Proxy.php in Leadpages trunk, at includes/Proxy.php

349 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Leadpages;
4
5 defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
6
7 use Leadpages\providers\Utils;
8 use Leadpages\providers\http\Client;
9 use Leadpages\providers\http\exceptions\ServerException;
10 use Leadpages\providers\http\exceptions\NotFoundException;
11 use Leadpages\models\Page;
12 use Leadpages\models\Options;
13 use Leadpages\PopupScope;
14 use Leadpages\serving\PageSource;
15 use Leadpages\serving\ClassicPageSource;
16 use Leadpages\serving\NovaPageSource;
17
18 /**
19 * A class for serving Leadpages assets within the WordPress environment.
20 *
21 * The reverse-proxy mechanics (fetch, render, cache) are shared; the backend-specific behavior for
22 * a connected page comes from a PageSource selected by the row's platform (Classic or Nova).
23 */
24 class Proxy {
25
26 use Utils;
27
28 /** @var Client */
29 private $client;
30
31 /** The serving-tag meta value injected into the current response (set per request in render). */
32 private static $serving_tag = 'wordpress-official';
33
34 /** The pop-up embed <script> injected before </body> for the current response, or '' for none. */
35 private static $popup_embed = '';
36
37 public function __construct() {
38 $this->client = new Client();
39 }
40
41 /**
42 * Proxy requests to connected Leadpages landing pages. Ignores any request methods that are not
43 * GET and any paths not associated with a connected landing page. The page is served through the
44 * PageSource for its platform.
45 *
46 * "path" and "slug" are used synonymously here and are the same as a WP "permalink"
47 *
48 * Renders the page html and exits the current process so no other code can execute.
49 *
50 * @return void
51 */
52 public function serve_landing_page() {
53 $request_method = sanitize_key($_SERVER['REQUEST_METHOD']);
54 if ('get' !== $request_method) {
55 return;
56 }
57
58 $start = microtime(true);
59
60 $current_url = $this->get_current_url();
61 $slug = sanitize_title($this->parse_request($current_url));
62
63 $page = Page::get_by_slug($slug);
64 if (! $page) {
65 $this->debug("Ignoring request to $current_url");
66 return;
67 }
68
69 $source = $this->source_for($page);
70 $cache_key = $source->cache_key($page);
71
72 // The proxy exits before wp_enqueue_scripts runs, so the site-wide pop-up enqueue never fires
73 // for proxied pages. Inject the same embed here, gated by the same scope (matched on this
74 // request's slug) so a "specific pages" selection covers proxied pages too.
75 self::$popup_embed = $this->build_popup_embed($slug);
76
77 $cached_value = Cache::get($cache_key);
78 if ($cached_value) {
79 $this->debug('Serving page from cache');
80 $this->render_html($cached_value, $source);
81 } else {
82 // Do not serve unpublished, deleted, or split-test-variation pages.
83 if (! $page->current_edition || ! $page->connected || $page->deleted_at || $page->split_test) {
84 $this->debug("Ignoring request to $current_url");
85 return;
86 }
87
88 $wp_url = strtok($current_url, '?');
89 $this->debug("Proxying $current_url");
90 $response = $this->fetch_page_html($source, $page, $wp_url);
91 if (! $response) {
92 $this->debug('Something went wrong, aborting proxy');
93 return;
94 }
95
96 $this->render_html($response, $source);
97
98 // Never cache a response that carries Set-Cookie: the cached copy would replay that
99 // same cookie value to every visitor served from cache (a shared-identity vector).
100 // Per-visitor experiment/personalization responses are already no-store (ttl null);
101 // this also covers a cacheable page that happens to set a first-party cookie.
102 $ttl = $source->cache_ttl($page, $response);
103 if (null !== $ttl && empty(wp_remote_retrieve_cookies($response))) {
104 Cache::set($cache_key, $response, $ttl);
105 }
106 }
107
108 $end = microtime(true);
109 $time_taken = ( $end - $start ) * 1000;
110 $this->debug("Successful served page in $time_taken ms");
111
112 $this->lp_exit(0);
113 }
114
115 /**
116 * Select the serving strategy for a page based on its platform.
117 *
118 * @param object $page
119 * @return PageSource
120 */
121 private function source_for( $page ) {
122 if (isset($page->platform) && 'nova' === $page->platform) {
123 return new NovaPageSource();
124 }
125 return new ClassicPageSource();
126 }
127
128 /**
129 * Strip the base url and params out of the request url and differentiate the
130 * result against the users specified permalink structure. The result will be
131 * the slug we can expect a landing page to be published under.
132 *
133 * @param string $url
134 * @return string
135 */
136 private function parse_request( $url ) {
137 $path_and_params = substr($url, strlen(home_url()));
138 $path = explode('?', $path_and_params);
139 $tokens = explode('/', $path[0]);
140
141 $permalink_structure = $this->clean_permalink_for_leadpage();
142 $tokens = array_diff($tokens, $permalink_structure);
143
144 foreach ($tokens as $index => $token) {
145 if (empty($token)) {
146 unset($tokens[ $index ]);
147 } else {
148 $tokens[ $index ] = sanitize_title($token);
149 }
150 }
151 $tokens = array_values($tokens);
152 $slug = implode('/', $tokens);
153
154 return $slug;
155 }
156
157
158 /**
159 * Get the WordPress permalink structure with any %parameters% removed
160 *
161 * @return string[]
162 */
163 private function clean_permalink_for_leadpage() {
164 $permalink_structure = explode('/', Options::get(Options::$permalink_structure));
165 foreach ($permalink_structure as $key => $value) {
166 if (empty($value) || strpos($value, '%') !== false) {
167 unset($permalink_structure[ $key ]);
168 }
169 }
170 return $permalink_structure;
171 }
172
173 /**
174 * Fetch the upstream page HTML through the given source. Retries once on server errors. Returns
175 * the response to render, or null to decline serving (letting WordPress handle the request).
176 *
177 * @param PageSource $source
178 * @param object $page
179 * @param string $wp_url the public WordPress URL being served
180 * @param bool $retry whether to retry the request on server errors
181 * @return array|null response object or null
182 */
183 private function fetch_page_html( $source, $page, $wp_url, $retry = true ) {
184 $url = $source->build_url($page, $wp_url);
185 $options = $source->build_request_options();
186
187 try {
188 $response = $this->client->get($url, $options);
189 } catch (NotFoundException $e) {
190 // Classic serves the Leadpages 404 page; Nova declines so it never echoes an error page.
191 $response = $source->serves_not_found() ? $e->response : null;
192 } catch (ServerException $e) {
193 $status_code = wp_remote_retrieve_response_code($e->response);
194 if ($status_code >= 500 && $retry) {
195 $response = $this->fetch_page_html($source, $page, $wp_url, false);
196 } else {
197 $response = null;
198 }
199 } catch (\Exception $e) {
200 $response = null;
201 }
202
203 return $response;
204 }
205
206 /**
207 * Render HTML from an HTTP response object to the page with the same status code as the response
208 * and mirror the source's cookies back to the visitor.
209 *
210 * @param array $response
211 * @param PageSource $source
212 */
213 public function render_html( $response, $source ) {
214 if (ob_get_length() > 0) {
215 ob_clean();
216 }
217
218 $html = $response['body'];
219 $status = wp_remote_retrieve_response_code($response);
220
221 status_header($status);
222 foreach ($source->cookies_to_mirror($response) as $cookie) {
223 // Mirror on the WordPress host with a site-wide path so per-visitor experiment /
224 // personalization cookies are sent on every page (not just the proxied slug's
225 // directory, which is what a bare setcookie() would default to). Mark Secure on
226 // HTTPS and SameSite=Lax. HttpOnly is intentionally NOT forced: Nova's client-side
227 // scripts read experiment cookies (e.g. _hp_exp_*) from document.cookie.
228 setcookie(
229 $cookie->name,
230 $cookie->value,
231 [
232 'expires' => $cookie->expires ?? 0,
233 'path' => '/',
234 'secure' => is_ssl(),
235 'samesite' => 'Lax',
236 ]
237 );
238 }
239
240 self::$serving_tag = $source->serving_tag();
241
242 ob_start([ get_called_class(), 'preprocess_html' ]);
243 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
244 echo $html;
245 ob_end_flush();
246 }
247
248 /**
249 * Build the pop-up embed <script> tag for a served slug, or '' when no pop-up is selected or the
250 * slug is outside the configured scope. The URL is the same public embed the site-wide enqueue
251 * uses; PopupScope is the single source of truth so proxied pages honor the same selection.
252 *
253 * @param string $slug the slug of the page being served
254 * @return string
255 */
256 private function build_popup_embed( $slug ) {
257 $popup_id = PopupScope::selected_popup_id();
258 if (empty($popup_id) || ! PopupScope::allows_slug($slug)) {
259 return '';
260 }
261 // The proxy injects into already-rendered HTML and exits before wp_enqueue_scripts runs, so
262 // the embed cannot go through the enqueue pipeline and is emitted as a literal script tag.
263 // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- injected into proxied HTML.
264 return '<script async src="' . esc_url(PopupScope::embed_src($popup_id)) . '"></script>';
265 }
266
267 /**
268 * Wrap the exit construct for testing purposes
269 */
270 public function lp_exit() {
271 exit(0);
272 }
273
274 private static function get_current_url() {
275 $host = isset($_SERVER['HTTP_HOST']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])) : '';
276 $uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
277 return esc_url_raw(( is_ssl() ? 'https://' : 'http://' ) . $host . $uri);
278 }
279
280 /**
281 * Process HTML content for output buffering.
282 *
283 * @param string $content html
284 * @return string
285 */
286 public static function preprocess_html( $content ) {
287 $html = self::modify_url_tag($content);
288 $html = self::modify_serving_tags($html);
289 $html = self::modify_popup_embed($html);
290 return $html;
291 }
292
293 /**
294 * Output buffering callback to inject the Nova pop-up embed <script> before the closing </body>
295 * tag. No-op when no pop-up applies to this response (none selected or slug out of scope) or when
296 * the HTML has no </body>.
297 *
298 * @param string $content html
299 * @return string
300 */
301 public static function modify_popup_embed( $content ) {
302 if ('' === self::$popup_embed) {
303 return $content;
304 }
305 return str_replace('</body>', self::$popup_embed . '</body>', $content);
306 }
307
308 /**
309 * Output buffering callback to add a "leadpages-serving-tags" meta tag for analytics. This
310 * helps us differentiate WordPress traffic (and Classic vs Nova) in our system.
311 *
312 * @param string $content html
313 * @return string
314 */
315 public static function modify_serving_tags( $content ) {
316 $search = '</head>';
317 $replace = '<meta name="leadpages-serving-tags" content="' . self::$serving_tag . '"></head>';
318 return str_replace($search, $replace, $content);
319 }
320
321 /**
322 * Output buffering callback for "og:url" meta tag. Replace the tag content with
323 * the url of the WordPress page.
324 *
325 * Open Graph meta tags are snippets of code that control how URLs are displayed when shared
326 * on social media. A link to this page shared on social media should point to the users WP
327 * site and not the page within Leadpages.
328 *
329 * @param string $content html
330 * @return string
331 */
332 public static function modify_url_tag( $content ) {
333 global $wp;
334 if (empty($wp)) {
335 // we can't build the correct WP url in this case, so return the original page
336 return $content;
337 }
338
339 $url = self::get_current_url();
340 $regex = '/(<meta property="og:url" content=")[^"]+(">)/';
341 $html = preg_replace($regex, '${1}' . $url . '${2}', $content);
342 if (null === $html) {
343 // An error occured so we return the original content
344 return $content;
345 }
346 return $html;
347 }
348 }
349