PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/import/ExportService.php

449 lines 17.0 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 = ABJ_404_Solution_RequestInputNormalizer::readText($_REQUEST, array(
100 'name' => 'export_format',
101 'default' => 'native',
102 ));
103
104 if (array_key_exists($format, $this->serverFormatRegistry())) {
105 $this->doServerFormatExport($format);
106 return;
107 }
108
109 $tempFile = $this->getExportFilename($format);
110
111 if ($format === 'redirection') {
112 $nativeExportFile = $this->getExportFilename('native');
113 $this->viewReadService->doRedirectsExport($nativeExportFile);
114 $error = $this->convertExportCsvToRedirectionFormat($nativeExportFile, $tempFile);
115 if ($error !== '') {
116 $this->loggerWarn($error);
117 return;
118 }
119 } else {
120 $this->viewReadService->doRedirectsExport($tempFile);
121 }
122
123 if (file_exists($tempFile)) {
124 header('Content-Description: File Transfer');
125 header('Content-Disposition: attachment; filename=' . basename($tempFile));
126 header('Expires: 0');
127 header('Cache-Control: must-revalidate');
128 header('Pragma: public');
129 header('Content-Length: ' . filesize($tempFile));
130 header('Content-Type: text/csv; charset=utf-8');
131 readfile($tempFile);
132 exit();
133 }
134
135 $this->loggerInfo("I don't see any data to export.");
136 }
137
138 /**
139 * Serve a server-level or edge/CDN format export directly (no temp file needed).
140 *
141 * @param string $format One of: htaccess, nginx, cloudflare, netlify, vercel.
142 * @return void
143 */
144 private function doServerFormatExport($format) {
145 $registry = $this->serverFormatRegistry();
146 if (!array_key_exists($format, $registry)) {
147 $this->loggerWarn('Unknown server export format: ' . $format);
148 return;
149 }
150
151 $entry = $registry[$format];
152 $content = $this->{$entry['method']}();
153
154 header('Content-Description: File Transfer');
155 header('Content-Disposition: attachment; filename=' . $entry['filename']);
156 header('Expires: 0');
157 header('Cache-Control: must-revalidate');
158 header('Pragma: public');
159 header('Content-Length: ' . strlen($content));
160 header('Content-Type: ' . $entry['mime']);
161 echo $content;
162 exit();
163 }
164
165 /**
166 * Fetch all exportable (manual + regex, non-trashed) redirects and resolve
167 * destination URLs.
168 *
169 * Each returned element has:
170 * source string The from-URL stored in the DB (relative path or full URL).
171 * dest string Resolved destination URL or path.
172 * code int HTTP status code (301, 302, 410, ...).
173 * is_regex bool Whether this is a regex redirect.
174 *
175 * @return array<int, array{source: string, dest: string, code: int, is_regex: bool}>
176 */
177 function getExportableRedirects() {
178 if (!$this->redirectsRepository instanceof ABJ_404_Solution_RedirectsRepositoryInterface) {
179 $this->loggerWarn('Exportable redirects repository is not available for server-format export.');
180 return array();
181 }
182
183 return $this->redirectsRepository->getExportableRedirects();
184 }
185
186 /**
187 * @param string $message
188 * @return void
189 */
190 private function loggerWarn(string $message): void {
191 if (is_object($this->logger) && method_exists($this->logger, 'warn')) {
192 $this->logger->warn($message);
193 return;
194 }
195
196 $logger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
197 if (is_object($logger) && method_exists($logger, 'warn')) {
198 $logger->warn($message);
199 return;
200 }
201
202 abj404_logPhpFallback('service-resolution-fallback', $message);
203 }
204
205 /**
206 * @param string $message
207 * @return void
208 */
209 private function loggerInfo(string $message): void {
210 if (is_object($this->logger) && method_exists($this->logger, 'infoMessage')) {
211 $this->logger->infoMessage($message);
212 return;
213 }
214
215 $logger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
216 if (is_object($logger) && method_exists($logger, 'infoMessage')) {
217 $logger->infoMessage($message);
218 return;
219 }
220
221 abj404_logPhpFallback('service-resolution-fallback', $message);
222 }
223
224 /**
225 * Generate Apache .htaccess redirect rules.
226 *
227 * @return string
228 */
229 function generateHtaccessRules() {
230 $redirects = $this->getExportableRedirects();
231 $lines = array('# 404 Solution redirects', 'RewriteEngine On', '');
232
233 foreach ($redirects as $r) {
234 $source = $r['source'];
235 $dest = $r['dest'];
236 $code = $r['code'];
237
238 $pattern = ltrim($source, '/');
239
240 if (!$r['is_regex']) {
241 $pattern = preg_quote($pattern, '/');
242 $pattern = $pattern . '/?';
243 }
244
245 if ($code === 410 || $code === 451) {
246 // Apache [G] flag sends a 410 Gone response; it is the closest equivalent for 451.
247 $lines[] = 'RewriteRule ^' . $pattern . '$ - [G,L]';
248 } elseif ($code === 0) {
249 // Meta Refresh requires serving an HTML response, not representable as a RewriteRule.
250 $lines[] = '# Meta Refresh: ' . $source . ' to ' . $dest . ' (serve HTML; not representable as a RewriteRule)';
251 } else {
252 $flag = ($code === 301) ? 'R=301' : 'R=' . $code;
253 $lines[] = 'RewriteRule ^' . $pattern . '$ ' . $dest . ' [' . $flag . ',L]';
254 }
255 }
256
257 if (count($redirects) === 0) {
258 $lines[] = '# No manual redirects found.';
259 }
260
261 return implode("\n", $lines) . "\n";
262 }
263
264 /**
265 * Generate Nginx location block redirect rules.
266 *
267 * @return string
268 */
269 function generateNginxRules() {
270 $redirects = $this->getExportableRedirects();
271 $lines = array('# 404 Solution redirects', '');
272
273 foreach ($redirects as $r) {
274 $source = $r['source'];
275 $dest = $r['dest'];
276 $code = $r['code'];
277
278 if ($r['is_regex']) {
279 $directive = 'location ~* ' . $source;
280 } else {
281 $directive = 'location = ' . $source;
282 }
283
284 if ($code === 410 || $code === 451) {
285 $lines[] = $directive . ' { return ' . $code . '; }';
286 } elseif ($code === 0) {
287 $lines[] = '# Meta Refresh: ' . $source . ' to ' . $dest . ' (serve HTML; not representable as a return directive)';
288 } else {
289 $lines[] = $directive . ' { return ' . $code . ' ' . $dest . '; }';
290 }
291 }
292
293 if (count($redirects) === 0) {
294 $lines[] = '# No manual redirects found.';
295 }
296
297 return implode("\n", $lines) . "\n";
298 }
299
300 /**
301 * Generate a Cloudflare Workers JavaScript snippet for handling redirects.
302 *
303 * @return string
304 */
305 function generateCloudflareWorkerScript() {
306 $redirects = $this->getExportableRedirects();
307
308 $entries = array();
309 foreach ($redirects as $r) {
310 $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
311 $sourceJson = json_encode($r['source'], $jsonFlags);
312 $destJson = json_encode($r['dest'], $jsonFlags);
313 if ($sourceJson === false) {
314 $sourceJson = json_encode(mb_convert_encoding($r['source'], 'UTF-8', 'UTF-8'), $jsonFlags);
315 }
316 if ($destJson === false) {
317 $destJson = json_encode(mb_convert_encoding($r['dest'], 'UTF-8', 'UTF-8'), $jsonFlags);
318 }
319 if ($sourceJson === false || $destJson === false) {
320 continue;
321 }
322 $code = (int)$r['code'];
323 $entries[] = " " . $sourceJson . ": { dest: " . $destJson . ", status: " . $code . " }";
324 }
325
326 $map = implode(",\n", $entries);
327
328 $script = "const REDIRECTS = {\n";
329 $script .= ($map !== '' ? $map . "\n" : '');
330 $script .= "};\n";
331 $script .= "\n";
332 $script .= "addEventListener('fetch', event => {\n";
333 $script .= " event.respondWith(handleRequest(event.request));\n";
334 $script .= "});\n";
335 $script .= "\n";
336 $script .= "async function handleRequest(request) {\n";
337 $script .= " const url = new URL(request.url);\n";
338 $script .= " const rule = REDIRECTS[url.pathname] || REDIRECTS[url.pathname.replace(/\\/$/, '')];\n";
339 $script .= " if (rule) {\n";
340 $script .= " if (rule.status === 410 || rule.status === 451) return new Response(null, { status: rule.status });\n";
341 $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";
342 $script .= " return Response.redirect(rule.dest.startsWith('http') ? rule.dest : url.origin + rule.dest, rule.status);\n";
343 $script .= " }\n";
344 $script .= " return fetch(request);\n";
345 $script .= "}\n";
346
347 return $script;
348 }
349
350 /**
351 * Generate a Netlify _redirects file.
352 *
353 * @return string
354 */
355 function generateNetlifyRedirects() {
356 $redirects = $this->getExportableRedirects();
357 $lines = array('# 404 Solution redirects');
358
359 foreach ($redirects as $r) {
360 $source = $r['source'];
361 $dest = $r['dest'];
362 $code = (int)$r['code'];
363 $lines[] = $source . ' ' . $dest . ' ' . $code;
364 }
365
366 if (count($redirects) === 0) {
367 $lines[] = '# No manual redirects found.';
368 }
369
370 return implode("\n", $lines) . "\n";
371 }
372
373 /**
374 * Generate a Vercel redirects JSON array (for use in vercel.json).
375 *
376 * Note: Vercel does not natively support 410 Gone responses; those redirects
377 * are omitted from this export.
378 *
379 * @return string JSON array string.
380 */
381 function generateVercelRedirects() {
382 $redirects = $this->getExportableRedirects();
383 $entries = array();
384
385 foreach ($redirects as $r) {
386 if ($r['code'] === 410 || $r['code'] === 451 || $r['code'] === 0) {
387 continue;
388 }
389 $entries[] = array(
390 'source' => $r['source'],
391 'destination' => $r['dest'],
392 'permanent' => ($r['code'] === 301),
393 );
394 }
395
396 $encoded = json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
397 return is_string($encoded) ? $encoded : '[]';
398 }
399
400 /**
401 * Convert native export format to a Redirection-compatible CSV shape.
402 *
403 * @param string $sourceFile Native export file path.
404 * @param string $destinationFile Output file path.
405 * @return string Empty string on success, error message otherwise.
406 */
407 function convertExportCsvToRedirectionFormat($sourceFile, $destinationFile) {
408 if (!file_exists($sourceFile)) {
409 return __('Error: Native export file does not exist.', '404-solution');
410 }
411
412 $in = fopen($sourceFile, 'r');
413 if ($in === false) {
414 return __('Error: Could not read native export file.', '404-solution');
415 }
416
417 $out = fopen($destinationFile, 'w');
418 if ($out === false) {
419 fclose($in);
420 return __('Error: Could not create Redirection export file.', '404-solution');
421 }
422
423 fputcsv($out, array('source', 'target', 'regex', 'code'), ',', '"', '\\');
424 fgetcsv($in, 0, ',', '"', '\\');
425 while (($row = fgetcsv($in, 0, ',', '"', '\\')) !== false) {
426 if (!is_array($row) || count($row) < 4) {
427 continue;
428 }
429 $from = trim((string)$row[0]);
430 $status = trim((string)$row[1]);
431 $to = trim((string)$row[3]);
432 if ($from === '' || $to === '') {
433 continue;
434 }
435
436 $regexFlag = (strtolower($status) === 'regex') ? '1' : '0';
437 $code = isset($row[6]) ? trim((string)$row[6]) : '301';
438 if ($code === '' || !is_numeric($code)) {
439 $code = '301';
440 }
441 fputcsv($out, array($from, $to, $regexFlag, $code), ',', '"', '\\');
442 }
443
444 fclose($in);
445 fclose($out);
446 return '';
447 }
448 }
449