Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.22% covered (success)
97.22%
35 / 36
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentAttachmentDownloadWebController
97.14% covered (success)
97.14%
34 / 35
75.00% covered (warning)
75.00%
3 / 4
16
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
 actionDownload
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 validateAndResolveAttachment
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 streamFileResponse
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
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\Comments\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\PermissionContext;
12use App\Modules\Comments\Application\Service\CommentAttachmentServiceInterface;
13use App\Modules\Comments\Application\Service\CommentServiceInterface;
14use App\Modules\Comments\Domain\Model\CommentAttachment;
15use Nyholm\Psr7\Response;
16use Nyholm\Psr7\Stream;
17use Psr\Http\Message\ResponseInterface;
18
19/**
20 * Web controller handling secure authorized download and streaming of comment attachments.
21 *
22 * Enforces OWASP ASVS 5 / NIST compliance with sandbox CSP and nosniff protection.
23 *
24 * @package App\Modules\Comments\Presentation\Web
25 */
26final readonly class CommentAttachmentDownloadWebController
27{
28    private const string MIME_TEXT_PLAIN = 'text/plain';
29
30    /**
31     * CommentAttachmentDownloadWebController constructor.
32     */
33    public function __construct(
34        private CommentServiceInterface $commentService,
35        private CommentAttachmentServiceInterface $attachmentService
36    ) {
37    }
38
39    /**
40     * Downloads or previews an attachment by its secure public token.
41     */
42    public function actionDownload(string $token, ?PermissionContext $context = null): ResponseInterface
43    {
44        $resolved = $this->validateAndResolveAttachment($token, $context);
45        if ($resolved instanceof ResponseInterface) {
46            return $resolved;
47        }
48
49        [$attachment, $absPath] = $resolved;
50        return $this->streamFileResponse($attachment, $absPath);
51    }
52
53    /**
54     * @return ResponseInterface|array{0: CommentAttachment, 1: string}
55     */
56    private function validateAndResolveAttachment(
57        string $token,
58        ?PermissionContext $context
59    ): ResponseInterface|array {
60        if ($context !== null && !$context->isAuthenticated()) {
61            return new Response(401, ['Content-Type' => self::MIME_TEXT_PLAIN], 'Unauthorized.');
62        }
63
64        $attachment = $this->commentService->findAttachmentByToken($token);
65        $absPath = $attachment !== null
66            ? $this->attachmentService->resolveAbsolutePath($attachment->filePath)
67            : null;
68
69        $errorMsg = match (true) {
70            $attachment === null => 'Attachment not found.',
71            $absPath === null    => 'File missing on storage server.',
72            default              => null,
73        };
74
75        if ($errorMsg !== null) {
76            return new Response(404, ['Content-Type' => self::MIME_TEXT_PLAIN], $errorMsg);
77        }
78
79        return [$attachment, $absPath];
80    }
81
82    private function streamFileResponse(CommentAttachment $attachment, string $absPath): ResponseInterface
83    {
84        $handle = fopen($absPath, 'rb');
85        if ($handle === false) {
86            return new Response(500, ['Content-Type' => self::MIME_TEXT_PLAIN], 'Unable to open file resource.');
87        }
88
89        $stream = Stream::create($handle);
90        $cleanName = str_replace('"', '', $attachment->fileName);
91        $disposition = $attachment->isImage() || $attachment->fileExtension === 'pdf' ? 'inline' : 'attachment';
92
93        return new Response(200, [
94            'Content-Type'              => $attachment->mimeType ?: 'application/octet-stream',
95            'Content-Disposition'       => $disposition . '; filename="' . $cleanName . '"',
96            'Content-Length'            => (string) filesize($absPath),
97            'Cache-Control'             => 'private, no-cache, no-store, must-revalidate',
98            'X-Content-Type-Options'    => 'nosniff',
99            'Content-Security-Policy'   => "sandbox; default-src 'none'",
100            'X-Frame-Options'           => 'DENY',
101        ], $stream);
102    }
103}