feca-mailshots-plugin/feca_mailshots_plugin/src/Admin/DownloadPdfAdminPage.php

370 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\Application\MailshotRunService;
use FecaMailshots\Application\MailshotService;
use FecaMailshots\WordPress\WordPressFacade;
final class DownloadPdfAdminPage
{
use AdminRequestHelpers;
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_download_pdf_ui_result';
private const PAGE_SLUG = 'feca-mailshots-download-pdf';
private const DOWNLOAD_MEMORY_LIMIT_ENV = 'FECA_MAILSHOTS_DOWNLOAD_MEMORY_LIMIT';
private const DOWNLOAD_MEMORY_LIMIT_OPTION = 'feca_mailshots_download_memory_limit';
private const DEFAULT_DOWNLOAD_MEMORY_LIMIT = '512M';
private const NONCE_ACTION = 'feca_mailshots_download_pdf';
/** @var callable(): MailshotRunService */
private $runServiceFactory;
/** @var callable(): MailshotService */
private $mailshotServiceFactory;
private WordPressFacade $wp;
/** @param callable(): MailshotRunService $runServiceFactory @param callable(): MailshotService $mailshotServiceFactory */
public function __construct(callable $runServiceFactory, callable $mailshotServiceFactory, WordPressFacade $wp)
{
$this->runServiceFactory = $runServiceFactory;
$this->mailshotServiceFactory = $mailshotServiceFactory;
$this->wp = $wp;
}
public function register(): void
{
$this->wp->addAction('admin_menu', [$this, 'registerMenu']);
$this->wp->addAction('admin_post_feca_mailshots_download_pdf_ui', [$this, 'handleDownloadUi']);
}
public function registerMenu(): void
{
$this->wp->addSubmenuPage('feca-mailshot', 'Download PDF', 'Download PDF', self::CAPABILITY, self::PAGE_SLUG, [$this, 'render']);
}
public function render(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
echo 'Permission denied';
return;
}
$mailshots = $this->mailshotService()->list();
$selectedMailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
if ($selectedMailshotId <= 0 && $mailshots !== []) {
$selectedMailshotId = (int) ($mailshots[0]['id'] ?? 0);
}
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
echo '<div class="wrap feca-mailshots-admin"><h1>Download PDF</h1>';
echo $this->renderAdminUiStyles();
echo '<p>Run the selected mailshot without sending any email, and download generated PDF attachments.</p>';
if ($result !== null) {
$ok = !empty($result['ok']);
$bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
echo '<div class="feca-banner ' . $bannerClass . '">';
echo '<strong>' . ($ok ? 'PDF generation succeeded.' : 'PDF generation failed.') . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
} else {
echo '<p class="feca-banner-note">Generated: ' . (int) ($result['generated_count'] ?? 0)
. ', Recipients: ' . (int) ($result['recipient_count'] ?? 0)
. ', Skipped: ' . (int) ($result['skipped_count'] ?? 0) . '</p>';
}
echo '</div>';
}
echo '<form id="feca-download-pdf-form" class="feca-form feca-form-max-860" method="post" action="' . $action . '">';
echo '<input type="hidden" name="action" value="feca_mailshots_download_pdf_ui">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" id="feca-download-format-shadow" name="download_format" value="">';
echo '<div class="feca-control-row">';
echo '<div class="feca-control feca-control-min-360">';
echo '<label for="dp_mailshot_id"><strong>Mailshot</strong></label>';
echo '<select id="dp_mailshot_id" name="mailshot_id">';
foreach ($mailshots as $mailshot) {
$id = (int) ($mailshot['id'] ?? 0);
$purpose = trim((string) ($mailshot['Purpose'] ?? ''));
if ($purpose === '') {
$purpose = 'Mailshot #' . $id;
}
$selected = $id === $selectedMailshotId ? ' selected' : '';
echo '<option value="' . $id . '"' . $selected . '>' . htmlspecialchars($purpose) . '</option>';
}
echo '</select></div></div>';
echo '<p class="feca-button-row">';
echo '<button id="feca-download-merged-btn" class="button button-primary" type="submit" name="download_format" value="merged" onclick="return confirm(\'Generate and download a single merged PDF?\');">Download Merged PDF</button> ';
echo '<button id="feca-download-zip-btn" class="button" type="submit" name="download_format" value="zip" onclick="return confirm(\'Generate and download a ZIP of individual PDFs?\');">Download ZIP of PDFs</button>';
echo '</p>';
echo '<div id="feca-download-progress" class="feca-progress-box">';
echo '<strong>Generating PDFs...</strong> Please wait. This can take a few seconds for larger mailshots.';
echo '</div>';
echo '</form>';
echo '<script>';
echo '(function(){';
echo 'var actualForm=document.getElementById("feca-download-pdf-form");';
echo 'if(!actualForm){return;}';
echo 'var merged=document.getElementById("feca-download-merged-btn");';
echo 'var zip=document.getElementById("feca-download-zip-btn");';
echo 'var progress=document.getElementById("feca-download-progress");';
echo 'var shadow=document.getElementById("feca-download-format-shadow");';
echo 'var mailshotSelect=document.getElementById("dp_mailshot_id");';
echo 'var selectedKey="fecaMailshotsSelectedMailshotId";';
echo 'function hasOption(value){if(!mailshotSelect){return false;}for(var i=0;i<mailshotSelect.options.length;i++){if(String(mailshotSelect.options[i].value||"")===String(value||"")){return true;}}return false;}';
echo 'function currentUrl(){return new URL(window.location.href);}';
echo 'function updateUrl(value){try{var url=currentUrl();url.searchParams.set("page","' . self::PAGE_SLUG . '");if(value){url.searchParams.set("mailshot_id",String(value));}else{url.searchParams.delete("mailshot_id");}window.history.replaceState({},"",url.toString());}catch(_){}}';
echo 'try{var url=currentUrl();var hasExplicit=url.searchParams.has("mailshot_id");var stored=String(sessionStorage.getItem(selectedKey)||"").trim();if(!hasExplicit&&stored&&hasOption(stored)&&mailshotSelect&&String(mailshotSelect.value||"")!==stored){url.searchParams.set("page","' . self::PAGE_SLUG . '");url.searchParams.set("mailshot_id",stored);window.location.replace(url.toString());return;}}catch(_){}';
echo 'if(mailshotSelect){try{if(mailshotSelect.value){sessionStorage.setItem(selectedKey,String(mailshotSelect.value));}}catch(_){}updateUrl(String(mailshotSelect.value||""));mailshotSelect.addEventListener("change",function(){var value=String(mailshotSelect.value||"");try{if(value){sessionStorage.setItem(selectedKey,value);}else{sessionStorage.removeItem(selectedKey);}}catch(_){}updateUrl(value);});}';
echo 'var clearUi=function(){if(progress){progress.style.display="none";}if(merged){merged.disabled=false;merged.classList.remove("disabled");}if(zip){zip.disabled=false;zip.classList.remove("disabled");}};';
echo 'actualForm.addEventListener("submit",function(ev){';
echo 'var submitter=ev&&ev.submitter?ev.submitter:null;';
echo 'if(shadow&&submitter&&submitter.name==="download_format"){shadow.value=submitter.value||"";}';
echo 'if(merged){merged.disabled=true;merged.classList.add("disabled");}';
echo 'if(zip){zip.disabled=true;zip.classList.add("disabled");}';
echo 'if(progress){progress.style.display="block";}';
echo 'window.setTimeout(clearUi,45000);';
echo '});';
echo 'window.addEventListener("focus",clearUi);';
echo 'window.addEventListener("pageshow",clearUi);';
echo 'document.addEventListener("visibilitychange",function(){if(document.visibilityState==="visible"){clearUi();}});';
echo '})();';
echo '</script>';
echo '</div>';
}
public function handleDownloadUi(): void
{
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = $this->requestInt('mailshot_id', 0);
$format = $this->requestString('download_format', 'merged');
if (!in_array($format, ['merged', 'zip'], true)) {
$format = 'merged';
}
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
$downloadBaseName = $this->downloadBaseNameForMailshot($mailshotId);
if ($format === 'zip') {
try {
$result = $this->runService()->generatePdfZipToTemp($mailshotId);
} catch (\Throwable $e) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP generation failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
if (empty($result['ok'])) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
$zipPath = (string) ($result['zip_path'] ?? '');
if ($zipPath === '' || !is_file($zipPath)) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP generation failed: archive file not found.']]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
try {
$this->sendFileDownload($downloadBaseName . '_pdfs.zip', 'application/zip', $zipPath);
} catch (\Throwable $e) {
@unlink($zipPath);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP download failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
}
return;
}
try {
$result = $this->runService()->generatePdfBatch($mailshotId, true, false);
} catch (\Throwable $e) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
if (empty($result['ok'])) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
$mergedPath = (string) ($result['merged_pdf_path'] ?? '');
if ($mergedPath === '' || !is_file($mergedPath)) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF generation failed: output file not found.']]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
try {
$this->sendFileDownload($downloadBaseName . '_merged.pdf', 'application/pdf', $mergedPath);
} catch (\Throwable $e) {
@unlink($mergedPath);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF download failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
}
private function sendFileDownload(string $filename, string $contentType, string $path): void
{
if (headers_sent()) {
throw new \RuntimeException('Cannot send download: headers already sent.');
}
$size = @filesize($path);
if (!is_int($size) || $size <= 0) {
throw new \RuntimeException('Cannot send download: file is missing or empty.');
}
header('Content-Type: ' . $contentType);
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $filename) . '"');
header('Content-Length: ' . (string) $size);
header('X-Content-Type-Options: nosniff');
$in = @fopen($path, 'rb');
if (!is_resource($in)) {
throw new \RuntimeException('Cannot read download file.');
}
while (!feof($in)) {
$chunk = fread($in, 8192);
if ($chunk === false) {
fclose($in);
throw new \RuntimeException('Failed while reading download file.');
}
echo $chunk;
}
fclose($in);
@unlink($path);
exit;
}
private function runService(): MailshotRunService
{
return ($this->runServiceFactory)();
}
private function mailshotService(): MailshotService
{
return ($this->mailshotServiceFactory)();
}
private function downloadBaseNameForMailshot(int $mailshotId): string
{
try {
foreach ($this->mailshotService()->list() as $mailshot) {
if ((int) ($mailshot['id'] ?? 0) !== $mailshotId) {
continue;
}
$purpose = trim((string) ($mailshot['Purpose'] ?? ''));
if ($purpose !== '') {
return $this->safeDownloadBaseName($purpose);
}
}
} catch (\Throwable $e) {
// Fall back to a stable id-based name if the purpose cannot be loaded.
}
return 'mailshot_' . max(0, $mailshotId);
}
private function safeDownloadBaseName(string $name): string
{
$name = trim($name);
if ($name === '') {
return 'mailshot';
}
if (function_exists('iconv')) {
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name);
if (is_string($ascii) && trim($ascii) !== '') {
$name = $ascii;
}
}
$name = preg_replace('/[^A-Za-z0-9._-]+/', '_', $name) ?? '';
$name = preg_replace('/_+/', '_', $name) ?? '';
$name = trim($name, '._-');
return $name !== '' ? $name : 'mailshot';
}
private function maybeRaiseMemoryLimit(string $target): void
{
if (!function_exists('ini_get') || !function_exists('ini_set')) {
return;
}
$current = (string) ini_get('memory_limit');
$currentBytes = $this->memoryLimitToBytes($current);
$targetBytes = $this->memoryLimitToBytes($target);
if ($currentBytes < 0 || $targetBytes <= 0) {
return;
}
if ($currentBytes >= $targetBytes) {
return;
}
$old = $current;
@ini_set('memory_limit', $target);
$new = (string) ini_get('memory_limit');
if ($new === $old) {
return;
}
}
private function resolveDownloadMemoryLimitTarget(): string
{
$optionValue = $this->wp->getOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, '');
if (is_string($optionValue)) {
$v = trim($optionValue);
if ($this->memoryLimitToBytes($v) > 0) {
return $v;
}
}
$envValue = getenv(self::DOWNLOAD_MEMORY_LIMIT_ENV);
if (is_string($envValue)) {
$v = trim($envValue);
if ($this->memoryLimitToBytes($v) > 0) {
return $v;
}
}
return self::DEFAULT_DOWNLOAD_MEMORY_LIMIT;
}
private function memoryLimitToBytes(string $limit): int
{
$v = trim($limit);
if ($v === '') {
return 0;
}
if ($v === '-1') {
return -1;
}
$unit = strtolower(substr($v, -1));
if (ctype_alpha($unit)) {
$num = (float) substr($v, 0, -1);
switch ($unit) {
case 'g':
return (int) ($num * 1024 * 1024 * 1024);
case 'm':
return (int) ($num * 1024 * 1024);
case 'k':
return (int) ($num * 1024);
default:
return (int) $num;
}
}
return (int) $v;
}
}