Procházet zdrojové kódy

HeadlessChrome Update

Benjamin Harris před 2 měsíci
rodič
revize
0978fb44ce

+ 3 - 1
composer.json

@@ -1,6 +1,8 @@
 {
     "require": {
         "phpmailer/phpmailer": "^6.9",
-        "smalot/pdfparser": "^2.0"
+        "smalot/pdfparser": "^2.0",
+        "erusev/parsedown": "^1.7",
+        "daandesmedt/php-headless-chrome": "^1.1"
     }
 }

+ 1 - 1
dashboard/crop-analysis/soil-test-data/soil-analysis-bs.php

@@ -62,7 +62,7 @@ function ratioBar(float $a, float $b, float $idealMin, float $idealMax): string
     <title>Soil Analysis Ratios | Crop Monitor</title>
     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
     <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.9.0/css/all.css" crossorigin="anonymous" rel="stylesheet">
-    <link href="/client-assets/css/dashboard-2021.css" rel="stylesheet">
+    <link href="/client-assets/css/dashboard.css" rel="stylesheet">
     <script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.9.3/html2pdf.bundle.min.js" crossorigin="anonymous"></script>
     <style>
         @media print {

+ 59 - 40
dashboard/crop-analysis/soil-test-data/soil-report-pdf.php

@@ -6,25 +6,33 @@
  * Displays the saved AI-generated sections from the reports table alongside
  * the element requirement cards and five-year balancing plan.
  *
- * Access: ?rid=<soil_records.id>&rand=<soil_records.rand>
+ * Normal access (logged-in user):
+ *   ?rid=<id>&rand=<token>
+ *
+ * Headless Chrome access (no session, called from headlessChrome_pdf.php):
+ *   ?rid=<id>&rand=<token>&ptoken=<one-time-file-token>
+ *   The ptoken is a short-lived file written by the generator. This lets Chrome
+ *   render the page without a PHP session cookie.
  */
 
 require_once __DIR__ . '/../../../config/database.php';
 require_once __DIR__ . '/../../../lib/auth.php';
+require_once __DIR__ . '/../../../lib/print_auth.php';
 require_once __DIR__ . '/../../../lib/soil_calculations.php';
+require_once __DIR__ . '/../../../vendor/autoload.php';
 
 if (session_status() === PHP_SESSION_NONE) {
     session_start();
 }
 
-requireLogin();
-
-$pdo    = getDBConnection();
-$userId = getCurrentUserId();
-
 $recordId = (int)  ($_GET['rid']  ?? 0);
 $randId   = trim(  $_GET['rand']  ?? '');
 
+$chromeAccess = authenticatePrintPage($recordId, $randId);
+
+$pdo    = getDBConnection();
+$userId = $chromeAccess ? null : getCurrentUserId();
+
 $row  = null;
 $spec = [];
 $savedComments = [];
@@ -43,11 +51,18 @@ if ($recordId > 0 && $randId !== '') {
             $spec = $stmtSpec->fetch(PDO::FETCH_ASSOC) ?: [];
         }
 
-        // Load saved report comments (JSON blob)
-        $stmtRpt = $pdo->prepare(
-            'SELECT comment FROM reports WHERE record_id = ? AND modx_user_id = ? ORDER BY id DESC LIMIT 1'
-        );
-        $stmtRpt->execute([$recordId, $userId]);
+        // Load saved report comments — use modx_user_id when available, else any comment for this record
+        if ($userId !== null) {
+            $stmtRpt = $pdo->prepare(
+                'SELECT comment FROM reports WHERE record_id = ? AND modx_user_id = ? ORDER BY id DESC LIMIT 1'
+            );
+            $stmtRpt->execute([$recordId, $userId]);
+        } else {
+            $stmtRpt = $pdo->prepare(
+                'SELECT comment FROM reports WHERE record_id = ? ORDER BY id DESC LIMIT 1'
+            );
+            $stmtRpt->execute([$recordId]);
+        }
         $savedRow = $stmtRpt->fetchColumn();
         if ($savedRow) {
             $decoded = json_decode($savedRow, true);
@@ -85,18 +100,15 @@ function calcDeficitPdf(array $row, array $spec, string $col, string $minCol, st
 
 $h = fn($v) => htmlspecialchars((string)($v ?? ''), ENT_QUOTES, 'UTF-8');
 
-// Format saved text: preserve newlines as paragraphs
+// Parse and render markdown text from AI-generated report sections
 function formatReportText(string $text): string
 {
-    if (trim($text) === '') return '<p class="text-muted fst-italic">No content saved.</p>';
-    $paragraphs = preg_split('/\n{2,}/', trim($text));
-    $out = '';
-    foreach ($paragraphs as $para) {
-        $para = trim($para);
-        if ($para === '') continue;
-        $out .= '<p>' . nl2br(htmlspecialchars($para, ENT_QUOTES, 'UTF-8')) . '</p>';
+    if (trim($text) === '') {
+        return '<p class="text-muted fst-italic">No content saved.</p>';
     }
-    return $out ?: '<p class="text-muted fst-italic">No content saved.</p>';
+    $parsedown = new Parsedown();
+    $parsedown->setSafeMode(true);  // strip any raw HTML in the text
+    return $parsedown->text($text);
 }
 ?>
 <!doctype html>
@@ -107,8 +119,7 @@ function formatReportText(string $text): string
     <title>Soil Analysis Report | Crop Monitor</title>
     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
     <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.9.0/css/all.css" rel="stylesheet" crossorigin="anonymous">
-    <link href="/client-assets/css/dashboard-2021.css" rel="stylesheet">
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.9.3/html2pdf.bundle.min.js" crossorigin="anonymous"></script>
+    <link href="/client-assets/css/dashboard.css" rel="stylesheet">
     <style>
         @media print {
             @page { size: A4 portrait; margin: 12mm; }
@@ -117,9 +128,29 @@ function formatReportText(string $text): string
             body           { font-size: 11px; }
             .report-section p { font-size: 11px; }
         }
-        .report-section p {
-            margin-bottom: 0.6rem;
-            line-height: 1.6;
+        .report-section p   { margin-bottom: 0.6rem; line-height: 1.6; }
+        .report-section h1,
+        .report-section h2,
+        .report-section h3,
+        .report-section h4  { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.4rem; }
+        .report-section ul,
+        .report-section ol  { padding-left: 1.4rem; margin-bottom: 0.6rem; }
+        .report-section li  { margin-bottom: 0.25rem; line-height: 1.5; }
+        .report-section table {
+            width: 100%;
+            border-collapse: collapse;
+            margin-bottom: 0.8rem;
+            font-size: 0.85rem;
+        }
+        .report-section table th,
+        .report-section table td {
+            border: 1px solid #dee2e6;
+            padding: 4px 8px;
+            text-align: left;
+        }
+        .report-section table thead th {
+            background: #f8f9fa;
+            font-weight: 600;
         }
         .section-header {
             background: #212529;
@@ -204,9 +235,10 @@ function formatReportText(string $text): string
            class="btn btn-outline-secondary btn-sm">
             &larr; Back to Report
         </a>
-        <button class="btn btn-success btn-sm" id="btn-download">
+        <a href="/pdf-files/headlessChrome_pdf.php?type=soil-report&rid=<?= $recordId ?>&rand=<?= urlencode($randId) ?>"
+           class="btn btn-success btn-sm">
             <i class="fas fa-download me-1"></i>Download PDF
-        </button>
+        </a>
         <button class="btn btn-outline-dark btn-sm" onclick="window.print()">
             <i class="fas fa-print me-1"></i>Print
         </button>
@@ -321,19 +353,6 @@ function formatReportText(string $text): string
 </div><!-- /container -->
 
 <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
-<script>
-document.getElementById('btn-download')?.addEventListener('click', function () {
-    var element = document.getElementById('pdf-content');
-    var opt = {
-        margin:      10,
-        filename:    'soil-report-<?= $h($row['lab_no'] ?? $recordId) ?>.pdf',
-        image:       { type: 'jpeg', quality: 1.0 },
-        html2canvas: { scale: 2, letterRendering: true, windowWidth: 1024, useCORS: true },
-        jsPDF:       { orientation: 'portrait', unit: 'mm', format: 'a4' }
-    };
-    html2pdf().from(element).set(opt).save();
-});
-</script>
 
 </body>
 </html>

+ 65 - 0
lib/print_auth.php

@@ -0,0 +1,65 @@
+<?php
+/**
+ * lib/print_auth.php
+ *
+ * Shared authentication for PDF print pages.
+ *
+ * Print pages can be accessed two ways:
+ *   1. Normal browser access — requires an active PHP session (requireLogin)
+ *   2. Headless Chrome access — no session; validated via a one-time token
+ *      file written by pdf-files/headlessChrome_pdf.php
+ *
+ * Usage (at the top of any print page, after requireLogin is available):
+ *
+ *   require_once __DIR__ . '/../../../lib/print_auth.php';
+ *   $chromeAccess = authenticatePrintPage($recordId, $randId);
+ *   $userId = $chromeAccess ? null : getCurrentUserId();
+ */
+
+/**
+ * Authenticate a print page request.
+ *
+ * Returns true if access was granted via a valid headless Chrome ptoken.
+ * Returns false if access was granted via normal session login.
+ * Dies with 403 if neither is valid.
+ */
+function authenticatePrintPage(int $recordId, string $randId): bool
+{
+    $ptoken = trim($_GET['ptoken'] ?? '');
+
+    if ($ptoken !== '') {
+        // Validate token format first (prevents path traversal)
+        if (!preg_match('/^[a-f0-9]{32}$/', $ptoken)) {
+            http_response_code(403);
+            die('Invalid print token.');
+        }
+
+        $tokenFile = dirname(__DIR__) . '/pdf-files/tokens/' . $ptoken . '.tmp';
+
+        if (!file_exists($tokenFile)) {
+            http_response_code(403);
+            die('Print token not found or already used.');
+        }
+
+        $tokenData = json_decode(file_get_contents($tokenFile), true);
+
+        if (
+            !is_array($tokenData)
+            || (int)$tokenData['rid']     !== $recordId
+            || $tokenData['rand']         !== $randId
+            || (int)$tokenData['expires']  <  time()
+        ) {
+            @unlink($tokenFile);
+            http_response_code(403);
+            die('Invalid or expired print token.');
+        }
+
+        // Token is valid — do NOT delete here; Chrome may make multiple requests
+        // for embedded resources. The generator deletes it after Chrome finishes.
+        return true;
+    }
+
+    // Fall back to session auth
+    requireLogin();
+    return false;
+}

+ 230 - 0
pdf-files/headlessChrome_pdf.php

@@ -0,0 +1,230 @@
+<?php
+/**
+ * pdf-files/headlessChrome_pdf.php
+ *
+ * Generic headless Chrome PDF generator.
+ * Renders any registered report type to a native Chrome PDF.
+ *
+ * GET params:
+ *   type   string  Report type key (see $reportTypes below)
+ *   rid    int     Primary record ID
+ *   rand   string  Security token (soil_records.rand / plant_records.rand etc.)
+ *   cid    int     Client ID (optional, passed through to print page)
+ *   stid   string  Soil/crop type (optional, passed through)
+ *
+ * Usage examples:
+ *   /pdf-files/headlessChrome_pdf.php?type=soil&rid=123&rand=abc
+ *   /pdf-files/headlessChrome_pdf.php?type=soil-combined&rid=123&rand=abc
+ *   /pdf-files/headlessChrome_pdf.php?type=plant&rid=456&rand=def&cid=12
+ *
+ * To add a new report type: add an entry to $reportTypes below.
+ */
+
+require_once __DIR__ . '/../config/database.php';
+require_once __DIR__ . '/../lib/auth.php';
+require_once __DIR__ . '/../vendor/autoload.php';
+
+use daandesmedt\PHPHeadlessChrome\HeadlessChrome;
+
+if (session_status() === PHP_SESSION_NONE) {
+    session_start();
+}
+
+requireLogin();
+
+// ── Input ─────────────────────────────────────────────────────────────────────
+$type     = trim($_GET['type']  ?? 'soil');
+$recordId = (int)  ($_GET['rid']   ?? 0);
+$randId   = trim(  $_GET['rand']   ?? '');
+$clientId = (int)  ($_GET['cid']   ?? 0);
+$stid     = trim(  $_GET['stid']   ?? '');
+
+// ── Report type registry ──────────────────────────────────────────────────────
+//
+// 'print_page'    — path relative to site root; {rid}, {rand}, {cid}, {stid}
+//                   placeholders are substituted at runtime
+// 'verify_table'  — DB table used to verify the rand token before generating
+// 'filename'      — prefix for the downloaded PDF filename
+// 'label'         — human-readable name (for error messages)
+//
+$reportTypes = [
+    // Individual pages
+    'soil-analysis' => [
+        'print_page'   => '/dashboard/crop-analysis/soil-test-data/soil-analysis.php?rid={rid}&rand={rand}&cid={cid}&stid={stid}&print=1',
+        'verify_table' => 'soil_records',
+        'filename'     => 'soil-analysis',
+        'label'        => 'Soil Analysis',
+    ],
+    'soil-report' => [
+        'print_page'   => '/dashboard/crop-analysis/soil-test-data/soil-report-pdf.php?rid={rid}&rand={rand}',
+        'verify_table' => 'soil_records',
+        'filename'     => 'soil-report',
+        'label'        => 'Soil Report',
+    ],
+    // Combined: analysis + AI report in one PDF
+    'soil' => [
+        'print_page'   => '/dashboard/crop-analysis/soil-test-data/soil-print-combined.php?rid={rid}&rand={rand}&cid={cid}&stid={stid}',
+        'verify_table' => 'soil_records',
+        'filename'     => 'soil-analysis-report',
+        'label'        => 'Soil Analysis & Report',
+    ],
+    // Plant
+    'plant-analysis' => [
+        'print_page'   => '/dashboard/crop-analysis/plant-test-data/plant-analysis-print.php?rid={rid}&rand={rand}&cid={cid}',
+        'verify_table' => 'plant_records',
+        'filename'     => 'plant-analysis',
+        'label'        => 'Plant Analysis',
+    ],
+    'plant-report' => [
+        'print_page'   => '/dashboard/crop-analysis/plant-test-data/plant-report-pdf.php?rid={rid}&rand={rand}',
+        'verify_table' => 'plant_records',
+        'filename'     => 'plant-report',
+        'label'        => 'Plant Report',
+    ],
+    'plant' => [
+        'print_page'   => '/dashboard/crop-analysis/plant-test-data/plant-print-combined.php?rid={rid}&rand={rand}&cid={cid}',
+        'verify_table' => 'plant_records',
+        'filename'     => 'plant-analysis-report',
+        'label'        => 'Plant Analysis & Report',
+    ],
+    // Water
+    'water' => [
+        'print_page'   => '/dashboard/crop-analysis/water-test-data/water-print-combined.php?rid={rid}&rand={rand}&cid={cid}',
+        'verify_table' => 'water_records',
+        'filename'     => 'water-analysis-report',
+        'label'        => 'Water Analysis & Report',
+    ],
+    // Animal
+    'animal' => [
+        'print_page'   => '/dashboard/crop-analysis/animal-dietary-balance/animal-print-combined.php?rid={rid}&rand={rand}&cid={cid}',
+        'verify_table' => 'animal_records',
+        'filename'     => 'animal-dietary-report',
+        'label'        => 'Animal Dietary Report',
+    ],
+];
+
+if (!isset($reportTypes[$type])) {
+    http_response_code(400);
+    $valid = implode(', ', array_keys($reportTypes));
+    die("Unknown report type \"" . htmlspecialchars($type, ENT_QUOTES, 'UTF-8') . "\". Valid types: $valid");
+}
+
+$config = $reportTypes[$type];
+
+if ($recordId <= 0 || $randId === '') {
+    http_response_code(400);
+    die('Missing required parameters: rid, rand');
+}
+
+// ── Verify record exists via its table ───────────────────────────────────────
+$pdo   = getDBConnection();
+$table = $config['verify_table'];
+
+// Allowlist the table name against known tables to prevent SQL injection
+$allowedTables = ['soil_records', 'plant_records', 'water_records', 'animal_records'];
+if (!in_array($table, $allowedTables, true)) {
+    http_response_code(500);
+    die('Invalid verify_table in report type config.');
+}
+
+$stmt = $pdo->prepare("SELECT lab_no FROM `{$table}` WHERE id = ? AND rand = ? LIMIT 1");
+$stmt->execute([$recordId, $randId]);
+$record = $stmt->fetch();
+
+if (!$record) {
+    http_response_code(404);
+    die('Record not found or access denied.');
+}
+
+// ── Write one-time print token ────────────────────────────────────────────────
+$token    = bin2hex(random_bytes(16));
+$expires  = time() + 120;
+
+$tokenDir = __DIR__ . '/tokens';
+if (!is_dir($tokenDir)) {
+    mkdir($tokenDir, 0750, true);
+}
+
+file_put_contents($tokenDir . '/' . $token . '.tmp', json_encode([
+    'rid'     => $recordId,
+    'rand'    => $randId,
+    'expires' => $expires,
+    'type'    => $type,
+]));
+
+// ── Build print page URL ──────────────────────────────────────────────────────
+$scheme  = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+$host    = $_SERVER['HTTP_HOST'] ?? 'localhost';
+$baseUrl = $scheme . '://' . $host;
+
+$printPath = str_replace(
+    ['{rid}', '{rand}',              '{cid}',   '{stid}'],
+    [$recordId, urlencode($randId), $clientId, urlencode($stid)],
+    $config['print_page']
+);
+
+$printUrl = $baseUrl . $printPath . '&ptoken=' . urlencode($token);
+
+// ── Output directory + filename ───────────────────────────────────────────────
+$outputDir = __DIR__;
+$today     = date('Y-m-d');
+$labNo     = preg_replace('/[^A-Za-z0-9\-_]/', '_', $record['lab_no'] ?? $recordId);
+$filename  = $config['filename'] . '-' . $labNo . '-' . $today;
+
+// ── Headless Chrome ───────────────────────────────────────────────────────────
+$chromeBinary = '/usr/bin/google-chrome';
+foreach (['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium'] as $bin) {
+    if (file_exists($bin)) { $chromeBinary = $bin; break; }
+}
+
+$arguments = [
+    '--headless'                              => '',
+    '--disable-gpu'                           => '',
+    '--hide-scrollbars'                       => '',
+    '--enable-viewport'                       => '',
+    '--disable-web-security'                  => '',
+    '--run-all-compositor-stages-before-draw' => '',
+    '--virtual-time-budget'                   => '60000',
+    '--timeout='                              => '8000',
+    '--no-sandbox'                            => '',
+    '--disable-setuid-sandbox'                => '',
+];
+
+try {
+    $chrome = new HeadlessChrome();
+    $chrome->disablePDFHeader();
+    $chrome->setUrl($printUrl);
+    $chrome->setBinaryPath($chromeBinary);
+    $chrome->setOutputDirectory($outputDir);
+    $chrome->setChromeArguments($arguments);
+    $chrome->toPDF($filename . '.pdf');
+
+    $pdfPath = $chrome->getFilePath();
+} catch (Exception $e) {
+    error_log('HeadlessChrome PDF error [' . $type . ']: ' . $e->getMessage());
+    @unlink($tokenDir . '/' . $token . '.tmp');
+    http_response_code(500);
+    die('PDF generation failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'));
+}
+
+@unlink($tokenDir . '/' . $token . '.tmp');
+
+// ── Stream PDF ────────────────────────────────────────────────────────────────
+if (!file_exists($pdfPath)) {
+    http_response_code(500);
+    die('PDF file was not created. Ensure Chrome is installed at: ' . $chromeBinary);
+}
+
+chmod($pdfPath, 0644);
+
+header('Content-Type: application/pdf');
+header('Content-Disposition: attachment; filename="' . $filename . '.pdf"');
+header('Content-Length: ' . filesize($pdfPath));
+header('Expires: 0');
+header('Cache-Control: must-revalidate');
+header('Pragma: public');
+
+readfile($pdfPath);
+
+@unlink($pdfPath);
+exit;