375 lines
16 KiB
PHP
375 lines
16 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 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;
|
|
}
|
|
|
|
$bytes = (string) ($result['merged_pdf_bytes'] ?? '');
|
|
if ($bytes === '') {
|
|
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF bytes are empty.']]);
|
|
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
|
|
return;
|
|
}
|
|
try {
|
|
$this->sendBinaryDownload($downloadBaseName . '_merged.pdf', 'application/pdf', $bytes);
|
|
} catch (\Throwable $e) {
|
|
$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 sendBinaryDownload(string $filename, string $contentType, string $bytes): void
|
|
{
|
|
if (headers_sent()) {
|
|
throw new \RuntimeException('Cannot send download: headers already sent.');
|
|
}
|
|
header('Content-Type: ' . $contentType);
|
|
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $filename) . '"');
|
|
header('Content-Length: ' . strlen($bytes));
|
|
header('X-Content-Type-Options: nosniff');
|
|
echo $bytes;
|
|
exit;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|