PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / import / ExportService.php

ExportService.php in 404 Solution 4.3.0, at includes/import/ExportService.php

446 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Owns the redirect-export pipeline.
9 *
10 * Reads exportable (manual + regex, non-trashed) redirects, resolves their
11 * destinations, and shapes them into one of seven output formats:
12 * - native CSV (the round-trip-able shape this plugin's own importer reads)
13 * - Redirection-plugin CSV (delegated through a native-to-Redirection conversion)
14 * - Apache .htaccess RewriteRule lines
15 * - Nginx location-block rules
16 * - Cloudflare Workers JavaScript
17 * - Netlify _redirects file
18 * - Vercel redirects JSON
19 *
20 * Does NOT own the import pipeline. See ABJ_404_Solution_ImportService.
21 */
22 class ABJ_404_Solution_ExportService {
23
24 /** @var ABJ_404_Solution_ViewReadServiceInterface */
25 private $viewReadService;
26
27 /** @var mixed Logger-like object supplied by production or legacy tests. */
28 private $logger;
29
30 /** @var ABJ_404_Solution_RedirectsRepositoryInterface|null */
31 private $redirectsRepository;
32
33 /**
34 * Constructor supports three signatures for backward compatibility:
35 * (1) New: (ViewReadService, Logging, RedirectsRepository)
36 * (2) Alternate injected order: (ViewReadService, RedirectsRepository, Logging)
37 * (3) Legacy: (DataAccess, Logging) -- DataAccess implements ViewReadService methods
38 *
39 * @param mixed $viewReadServiceOrDataAccess
40 * @param mixed $loggingOrRedirectsRepository
41 * @param mixed $redirectsRepositoryOrLogging
42 */
43 function __construct($viewReadServiceOrDataAccess, $loggingOrRedirectsRepository, $redirectsRepositoryOrLogging = null) {
44 /** @var ABJ_404_Solution_ViewReadServiceInterface $viewReadServiceOrDataAccess */
45 $this->viewReadService = $viewReadServiceOrDataAccess;
46 $this->logger = $loggingOrRedirectsRepository;
47 $this->redirectsRepository = null;
48
49 if ($loggingOrRedirectsRepository instanceof ABJ_404_Solution_RedirectsRepositoryInterface) {
50 $this->redirectsRepository = $loggingOrRedirectsRepository;
51 /** @var ABJ_404_Solution_Logging $redirectsRepositoryOrLogging */
52 $this->logger = $redirectsRepositoryOrLogging;
53 } elseif ($redirectsRepositoryOrLogging instanceof ABJ_404_Solution_RedirectsRepositoryInterface) {
54 $this->redirectsRepository = $redirectsRepositoryOrLogging;
55 } elseif ($viewReadServiceOrDataAccess instanceof ABJ_404_Solution_RedirectsRepositoryInterface) {
56 $this->redirectsRepository = $viewReadServiceOrDataAccess;
57 } elseif (is_object($viewReadServiceOrDataAccess) && method_exists($viewReadServiceOrDataAccess, 'getRedirectsRepo')) {
58 $candidate = $viewReadServiceOrDataAccess->getRedirectsRepo();
59 if ($candidate instanceof ABJ_404_Solution_RedirectsRepositoryInterface) {
60 $this->redirectsRepository = $candidate;
61 }
62 }
63 }
64
65 /**
66 * @param string $format
67 * @return string
68 */
69 function getExportFilename($format = 'native') {
70 if ($format === 'redirection') {
71 return abj404_getUploadsDir() . 'export-redirection.csv';
72 }
73 return abj404_getUploadsDir() . 'export.csv';
74 }
75
76 /**
77 * Registry of server-level / edge formats served directly (no temp file).
78 *
79 * Each entry pairs the format key with (a) the generator method, (b) the
80 * filename presented to the browser, and (c) the Content-Type header.
81 * doExport() and doServerFormatExport() both consume this map; adding a
82 * new server-format is one row here, not two coupled edits in the
83 * whitelist + switch.
84 *
85 * @return array<string, array{method: string, filename: string, mime: string}>
86 */
87 private function serverFormatRegistry() {
88 return array(
89 'htaccess' => array('method' => 'generateHtaccessRules', 'filename' => 'redirects.htaccess', 'mime' => 'text/plain; charset=utf-8'),
90 'nginx' => array('method' => 'generateNginxRules', 'filename' => 'redirects-nginx.conf', 'mime' => 'text/plain; charset=utf-8'),
91 'cloudflare' => array('method' => 'generateCloudflareWorkerScript', 'filename' => 'redirects-worker.js', 'mime' => 'application/javascript; charset=utf-8'),
92 'netlify' => array('method' => 'generateNetlifyRedirects', 'filename' => '_redirects', 'mime' => 'text/plain; charset=utf-8'),
93 'vercel' => array('method' => 'generateVercelRedirects', 'filename' => 'vercel-redirects.json', 'mime' => 'application/json; charset=utf-8'),
94 );
95 }
96
97 /** @return void */
98 function doExport() {
99 $format = isset($_REQUEST['export_format']) ? sanitize_text_field((string)$_REQUEST['export_format']) : 'native';
100
101 if (array_key_exists($format, $this->serverFormatRegistry())) {
102 $this->doServerFormatExport($format);
103 return;
104 }
105
106 $tempFile = $this->getExportFilename($format);
107
108 if ($format === 'redirection') {
109 $nativeExportFile = $this->getExportFilename('native');
110 $this->viewReadService->doRedirectsExport($nativeExportFile);
111 $error = $this->convertExportCsvToRedirectionFormat($nativeExportFile, $tempFile);
112 if ($error !== '') {
113 $this->loggerWarn($error);
114 return;
115 }
116 } else {
117 $this->viewReadService->doRedirectsExport($tempFile);
118 }
119
120 if (file_exists($tempFile)) {
121 header('Content-Description: File Transfer');
122 header('Content-Disposition: attachment; filename=' . basename($tempFile));
123 header('Expires: 0');
124 header('Cache-Control: must-revalidate');
125 header('Pragma: public');
126 header('Content-Length: ' . filesize($tempFile));
127 header('Content-Type: text/csv; charset=utf-8');
128 readfile($tempFile);
129 exit();
130 }
131
132 $this->loggerInfo("I don't see any data to export.");
133 }
134
135 /**
136 * Serve a server-level or edge/CDN format export directly (no temp file needed).
137 *
138 * @param string $format One of: htaccess, nginx, cloudflare, netlify, vercel.
139 * @return void
140 */
141 private function doServerFormatExport($format) {
142 $registry = $this->serverFormatRegistry();
143 if (!array_key_exists($format, $registry)) {
144 $this->loggerWarn('Unknown server export format: ' . $format);
145 return;
146 }
147
148 $entry = $registry[$format];
149 $content = $this->{$entry['method']}();
150
151 header('Content-Description: File Transfer');
152 header('Content-Disposition: attachment; filename=' . $entry['filename']);
153 header('Expires: 0');
154 header('Cache-Control: must-revalidate');
155 header('Pragma: public');
156 header('Content-Length: ' . strlen($content));
157 header('Content-Type: ' . $entry['mime']);
158 echo $content;
159 exit();
160 }
161
162 /**
163 * Fetch all exportable (manual + regex, non-trashed) redirects and resolve
164 * destination URLs.
165 *
166 * Each returned element has:
167 * source string The from-URL stored in the DB (relative path or full URL).
168 * dest string Resolved destination URL or path.
169 * code int HTTP status code (301, 302, 410, ...).
170 * is_regex bool Whether this is a regex redirect.
171 *
172 * @return array<int, array{source: string, dest: string, code: int, is_regex: bool}>
173 */
174 function getExportableRedirects() {
175 if (!$this->redirectsRepository instanceof ABJ_404_Solution_RedirectsRepositoryInterface) {
176 $this->loggerWarn('Exportable redirects repository is not available for server-format export.');
177 return array();
178 }
179
180 return $this->redirectsRepository->getExportableRedirects();
181 }
182
183 /**
184 * @param string $message
185 * @return void
186 */
187 private function loggerWarn(string $message): void {
188 if (is_object($this->logger) && method_exists($this->logger, 'warn')) {
189 $this->logger->warn($message);
190 return;
191 }
192
193 $logger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
194 if (is_object($logger) && method_exists($logger, 'warn')) {
195 $logger->warn($message);
196 return;
197 }
198
199 abj404_logPhpFallback('service-resolution-fallback', $message);
200 }
201
202 /**
203 * @param string $message
204 * @return void
205 */
206 private function loggerInfo(string $message): void {
207 if (is_object($this->logger) && method_exists($this->logger, 'infoMessage')) {
208 $this->logger->infoMessage($message);
209 return;
210 }
211
212 $logger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
213 if (is_object($logger) && method_exists($logger, 'infoMessage')) {
214 $logger->infoMessage($message);
215 return;
216 }
217
218 abj404_logPhpFallback('service-resolution-fallback', $message);
219 }
220
221 /**
222 * Generate Apache .htaccess redirect rules.
223 *
224 * @return string
225 */
226 function generateHtaccessRules() {
227 $redirects = $this->getExportableRedirects();
228 $lines = array('# 404 Solution redirects', 'RewriteEngine On', '');
229
230 foreach ($redirects as $r) {
231 $source = $r['source'];
232 $dest = $r['dest'];
233 $code = $r['code'];
234
235 $pattern = ltrim($source, '/');
236
237 if (!$r['is_regex']) {
238 $pattern = preg_quote($pattern, '/');
239 $pattern = $pattern . '/?';
240 }
241
242 if ($code === 410 || $code === 451) {
243 // Apache [G] flag sends a 410 Gone response; it is the closest equivalent for 451.
244 $lines[] = 'RewriteRule ^' . $pattern . '$ - [G,L]';
245 } elseif ($code === 0) {
246 // Meta Refresh requires serving an HTML response, not representable as a RewriteRule.
247 $lines[] = '# Meta Refresh: ' . $source . ' to ' . $dest . ' (serve HTML; not representable as a RewriteRule)';
248 } else {
249 $flag = ($code === 301) ? 'R=301' : 'R=' . $code;
250 $lines[] = 'RewriteRule ^' . $pattern . '$ ' . $dest . ' [' . $flag . ',L]';
251 }
252 }
253
254 if (count($redirects) === 0) {
255 $lines[] = '# No manual redirects found.';
256 }
257
258 return implode("\n", $lines) . "\n";
259 }
260
261 /**
262 * Generate Nginx location block redirect rules.
263 *
264 * @return string
265 */
266 function generateNginxRules() {
267 $redirects = $this->getExportableRedirects();
268 $lines = array('# 404 Solution redirects', '');
269
270 foreach ($redirects as $r) {
271 $source = $r['source'];
272 $dest = $r['dest'];
273 $code = $r['code'];
274
275 if ($r['is_regex']) {
276 $directive = 'location ~* ' . $source;
277 } else {
278 $directive = 'location = ' . $source;
279 }
280
281 if ($code === 410 || $code === 451) {
282 $lines[] = $directive . ' { return ' . $code . '; }';
283 } elseif ($code === 0) {
284 $lines[] = '# Meta Refresh: ' . $source . ' to ' . $dest . ' (serve HTML; not representable as a return directive)';
285 } else {
286 $lines[] = $directive . ' { return ' . $code . ' ' . $dest . '; }';
287 }
288 }
289
290 if (count($redirects) === 0) {
291 $lines[] = '# No manual redirects found.';
292 }
293
294 return implode("\n", $lines) . "\n";
295 }
296
297 /**
298 * Generate a Cloudflare Workers JavaScript snippet for handling redirects.
299 *
300 * @return string
301 */
302 function generateCloudflareWorkerScript() {
303 $redirects = $this->getExportableRedirects();
304
305 $entries = array();
306 foreach ($redirects as $r) {
307 $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
308 $sourceJson = json_encode($r['source'], $jsonFlags);
309 $destJson = json_encode($r['dest'], $jsonFlags);
310 if ($sourceJson === false) {
311 $sourceJson = json_encode(mb_convert_encoding($r['source'], 'UTF-8', 'UTF-8'), $jsonFlags);
312 }
313 if ($destJson === false) {
314 $destJson = json_encode(mb_convert_encoding($r['dest'], 'UTF-8', 'UTF-8'), $jsonFlags);
315 }
316 if ($sourceJson === false || $destJson === false) {
317 continue;
318 }
319 $code = (int)$r['code'];
320 $entries[] = " " . $sourceJson . ": { dest: " . $destJson . ", status: " . $code . " }";
321 }
322
323 $map = implode(",\n", $entries);
324
325 $script = "const REDIRECTS = {\n";
326 $script .= ($map !== '' ? $map . "\n" : '');
327 $script .= "};\n";
328 $script .= "\n";
329 $script .= "addEventListener('fetch', event => {\n";
330 $script .= " event.respondWith(handleRequest(event.request));\n";
331 $script .= "});\n";
332 $script .= "\n";
333 $script .= "async function handleRequest(request) {\n";
334 $script .= " const url = new URL(request.url);\n";
335 $script .= " const rule = REDIRECTS[url.pathname] || REDIRECTS[url.pathname.replace(/\\/$/, '')];\n";
336 $script .= " if (rule) {\n";
337 $script .= " if (rule.status === 410 || rule.status === 451) return new Response(null, { status: rule.status });\n";
338 $script .= " if (rule.status === 0) return new Response('<meta http-equiv=\"refresh\" content=\"0;url=' + rule.dest + '\">', { status: 200, headers: { 'Content-Type': 'text/html' } });\n";
339 $script .= " return Response.redirect(rule.dest.startsWith('http') ? rule.dest : url.origin + rule.dest, rule.status);\n";
340 $script .= " }\n";
341 $script .= " return fetch(request);\n";
342 $script .= "}\n";
343
344 return $script;
345 }
346
347 /**
348 * Generate a Netlify _redirects file.
349 *
350 * @return string
351 */
352 function generateNetlifyRedirects() {
353 $redirects = $this->getExportableRedirects();
354 $lines = array('# 404 Solution redirects');
355
356 foreach ($redirects as $r) {
357 $source = $r['source'];
358 $dest = $r['dest'];
359 $code = (int)$r['code'];
360 $lines[] = $source . ' ' . $dest . ' ' . $code;
361 }
362
363 if (count($redirects) === 0) {
364 $lines[] = '# No manual redirects found.';
365 }
366
367 return implode("\n", $lines) . "\n";
368 }
369
370 /**
371 * Generate a Vercel redirects JSON array (for use in vercel.json).
372 *
373 * Note: Vercel does not natively support 410 Gone responses; those redirects
374 * are omitted from this export.
375 *
376 * @return string JSON array string.
377 */
378 function generateVercelRedirects() {
379 $redirects = $this->getExportableRedirects();
380 $entries = array();
381
382 foreach ($redirects as $r) {
383 if ($r['code'] === 410 || $r['code'] === 451 || $r['code'] === 0) {
384 continue;
385 }
386 $entries[] = array(
387 'source' => $r['source'],
388 'destination' => $r['dest'],
389 'permanent' => ($r['code'] === 301),
390 );
391 }
392
393 $encoded = json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
394 return is_string($encoded) ? $encoded : '[]';
395 }
396
397 /**
398 * Convert native export format to a Redirection-compatible CSV shape.
399 *
400 * @param string $sourceFile Native export file path.
401 * @param string $destinationFile Output file path.
402 * @return string Empty string on success, error message otherwise.
403 */
404 function convertExportCsvToRedirectionFormat($sourceFile, $destinationFile) {
405 if (!file_exists($sourceFile)) {
406 return __('Error: Native export file does not exist.', '404-solution');
407 }
408
409 $in = fopen($sourceFile, 'r');
410 if ($in === false) {
411 return __('Error: Could not read native export file.', '404-solution');
412 }
413
414 $out = fopen($destinationFile, 'w');
415 if ($out === false) {
416 fclose($in);
417 return __('Error: Could not create Redirection export file.', '404-solution');
418 }
419
420 fputcsv($out, array('source', 'target', 'regex', 'code'), ',', '"', '\\');
421 fgetcsv($in, 0, ',', '"', '\\');
422 while (($row = fgetcsv($in, 0, ',', '"', '\\')) !== false) {
423 if (!is_array($row) || count($row) < 4) {
424 continue;
425 }
426 $from = trim((string)$row[0]);
427 $status = trim((string)$row[1]);
428 $to = trim((string)$row[3]);
429 if ($from === '' || $to === '') {
430 continue;
431 }
432
433 $regexFlag = (strtolower($status) === 'regex') ? '1' : '0';
434 $code = isset($row[6]) ? trim((string)$row[6]) : '301';
435 if ($code === '' || !is_numeric($code)) {
436 $code = '301';
437 }
438 fputcsv($out, array($from, $to, $regexFlag, $code), ',', '"', '\\');
439 }
440
441 fclose($in);
442 fclose($out);
443 return '';
444 }
445 }
446