Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.22% covered (success)
97.22%
70 / 72
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
DocumentVerificationWebController
97.18% covered (success)
97.18%
69 / 71
75.00% covered (warning)
75.00%
3 / 4
17
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verify
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 lookupVerification
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
5.02
 renderFallbackHtml
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Pdf\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Database\FallbackPdoResolver;
12use PDO;
13use Psr\Http\Message\ResponseFactoryInterface;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16use Twig\Environment as TwigEnvironment;
17
18/**
19 * Web Controller for Public Digital Document Verification (/verify/{hash}).
20 *
21 * Checks document authenticity against cryptographic SHA-256 hash stored
22 * in the database and renders verification certificate details.
23 *
24 * @package App\Modules\Pdf\Presentation\Web
25 */
26final readonly class DocumentVerificationWebController
27{
28    private ?PDO $pdo;
29
30    /**
31     * DocumentVerificationWebController constructor.
32     *
33     * @param ResponseFactoryInterface $responseFactory PSR-17 response factory.
34     * @param TwigEnvironment|null     $twig            Twig template engine.
35     * @param PDO|null                 $pdo             Optional database PDO handle.
36     */
37    public function __construct(
38        private ResponseFactoryInterface $responseFactory,
39        private ?TwigEnvironment $twig = null,
40        ?PDO $pdo = null
41    ) {
42        $this->pdo = $pdo ?? FallbackPdoResolver::resolveDefaultConnection();
43    }
44
45    /**
46     * Handles public document verification requests: GET /verify/{hash}
47     *
48     * @param string $hash Document verification hash.
49     * @return ResponseInterface Rendered verification certificate page.
50     */
51    public function verify(string $hash): ResponseInterface
52    {
53        $cleanHash = trim(preg_replace('/[^0-9a-fA-F]/', '', $hash) ?? '');
54        $verificationData = $this->lookupVerification($cleanHash);
55
56        $context = [
57            'hash'         => $cleanHash,
58            'is_valid'     => $verificationData !== null,
59            'verification' => $verificationData,
60            'verified_at'  => date('Y-m-d H:i:s'),
61            'app_name'     => 'Ammonly Enterprise Suite',
62        ];
63
64        if ($this->twig !== null) {
65            try {
66                $html = $this->twig->render('modules/pdf_templates/verify.twig', $context);
67            } catch (\Throwable) {
68                $html = $this->renderFallbackHtml($context);
69            }
70        } else {
71            $html = $this->renderFallbackHtml($context);
72        }
73
74        $statusCode = $verificationData !== null ? 200 : 404;
75        $response = $this->responseFactory->createResponse($statusCode)
76            ->withHeader('Content-Type', 'text/html; charset=UTF-8')
77            ->withHeader('X-Robots-Tag', 'noindex, nofollow');
78
79        $response->getBody()->write($html);
80
81        return $response;
82    }
83
84    /**
85     * Queries database for verification record and updates audit counters.
86     *
87     * @param string $hash Sanitized document verification hash.
88     * @return array<string, mixed>|null Verification record dictionary or null.
89     */
90    private function lookupVerification(string $hash): ?array
91    {
92        if ($hash === '' || $this->pdo === null) {
93            return null;
94        }
95
96        try {
97            $stmt = $this->pdo->prepare(
98                'SELECT v.`id`, v.`document_hash`, v.`filename`, v.`module_name`, v.`record_id`, '
99                . 'v.`template_id`, v.`checksum_sha256`, v.`verified_count`, v.`first_verified_at`, '
100                . 'v.`last_verified_at`, v.`created_at`, t.`name` AS `template_name` '
101                . 'FROM `a_mod_pdf_verification_records` v '
102                . 'LEFT JOIN `a_mod_pdf_template_records` t ON t.`id` = v.`template_id` '
103                . 'WHERE v.`document_hash` = :hash LIMIT 1'
104            );
105            $stmt->execute([':hash' => $hash]);
106            $row = $stmt->fetch(PDO::FETCH_ASSOC);
107
108            if (is_array($row)) {
109                // Increment verification counter and touch timestamps
110                $upd = $this->pdo->prepare(
111                    'UPDATE `a_mod_pdf_verification_records` SET '
112                    . '`verified_count` = `verified_count` + 1, '
113                    . '`first_verified_at` = COALESCE(`first_verified_at`, NOW(6)), '
114                    . '`last_verified_at` = NOW(6) WHERE `id` = :id'
115                );
116                $upd->execute([':id' => $row['id']]);
117
118                return $row;
119            }
120        } catch (\Throwable) {
121            // Silently fall through to return null
122        }
123
124        return null;
125    }
126
127    /**
128     * Fallback standalone HTML template if Twig view is unavailable.
129     *
130     * @param array<string, mixed> $context Verification context data.
131     * @return string Standalone HTML document.
132     */
133    private function renderFallbackHtml(array $context): string
134    {
135        $isValid = (bool)$context['is_valid'];
136        $hash = htmlspecialchars((string)$context['hash'], ENT_QUOTES, 'UTF-8');
137        $statusClass = $isValid ? 'text-success' : 'text-danger';
138        $statusTitle = $isValid ? 'Authentic Document' : 'Unverified Document';
139        $statusIcon = $isValid ? '&#10004;' : '&#10008;';
140
141        $detailsHtml = '';
142        if ($isValid && isset($context['verification']) && is_array($context['verification'])) {
143            $v = $context['verification'];
144            $filename = htmlspecialchars((string)($v['filename'] ?? ''), ENT_QUOTES, 'UTF-8');
145            $module = htmlspecialchars((string)($v['module_name'] ?? ''), ENT_QUOTES, 'UTF-8');
146            $recId = (int)($v['record_id'] ?? 0);
147            $sha256 = htmlspecialchars((string)($v['checksum_sha256'] ?? ''), ENT_QUOTES, 'UTF-8');
148            $created = htmlspecialchars((string)($v['created_at'] ?? ''), ENT_QUOTES, 'UTF-8');
149
150            $detailsHtml = <<<HTML
151            <table class="table table-bordered mt-3 text-start">
152                <tr><th>File:</th><td>{$filename}</td></tr>
153                <tr><th>Source Module:</th><td>{$module} (ID: {$recId})</td></tr>
154                <tr><th>SHA-256 Checksum:</th><td><code class="text-break">{$sha256}</code></td></tr>
155                <tr><th>Generated:</th><td>{$created}</td></tr>
156            </table>
157            HTML;
158        }
159
160        return <<<HTML
161        <!DOCTYPE html>
162        <html lang="en">
163        <head>
164            <meta charset="utf-8">
165            <meta name="viewport" content="width=device-width, initial-scale=1">
166            <title>Document Authenticity Certificate</title>
167            <link rel="stylesheet" href="/assets/css/tabler.min.css">
168        </head>
169        <body class="d-flex flex-column bg-light py-5">
170            <div class="container container-tight my-auto">
171                <div class="card card-md text-center p-4 shadow-sm">
172                    <div class="display-3 {$statusClass} mb-2">{$statusIcon}</div>
173                    <h2 class="card-title {$statusClass} mb-1">{$statusTitle}</h2>
174                    <p class="text-muted">Unique Identifier: <code>{$hash}</code></p>
175                    {$detailsHtml}
176                    <div class="mt-4">
177                        <a href="/" class="btn btn-outline-secondary">Return to Home Page</a>
178                    </div>
179                </div>
180            </div>
181        </body>
182        </html>
183        HTML;
184    }
185}