Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.50% covered (success)
97.50%
39 / 40
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
DocumentDownloadWebController
97.44% covered (success)
97.44%
38 / 39
85.71% covered (warning)
85.71%
6 / 7
23
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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 checkDocumentAccess
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 resolveFileAbsolutePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 streamFileResponse
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 buildContentDisposition
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 textResponse
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Documents\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Service\UniversalCrudServiceInterface;
12use App\Core\Engine\Domain\Exception\PermissionDeniedException;
13use App\Core\Engine\Domain\Exception\RecordNotFoundException;
14use App\Core\Engine\Domain\Model\PermissionContext;
15use App\Core\Security\Path\SafeStoragePathResolver;
16use App\Modules\Documents\Application\Service\DocumentUploadService;
17use App\Modules\Documents\Domain\Model\DocumentFile;
18use Nyholm\Psr7\Response;
19use Nyholm\Psr7\Stream;
20use Psr\Http\Message\ResponseInterface;
21use Throwable;
22
23/**
24 * Web controller handling authorized document file download and streaming.
25 *
26 * @package App\Modules\Documents\Presentation\Web
27 */
28final readonly class DocumentDownloadWebController
29{
30    private const string CONTENT_TYPE_PLAIN = 'text/plain';
31
32    /**
33     * DocumentDownloadWebController constructor.
34     *
35     * @param DocumentUploadService               $uploadService Document file upload service.
36     * @param UniversalCrudServiceInterface|null $crudService   Optional CRUD engine service for authorization.
37     */
38    public function __construct(
39        private DocumentUploadService $uploadService,
40        private ?UniversalCrudServiceInterface $crudService = null,
41    ) {
42    }
43
44    /**
45     * Downloads an attached document file by document ID and file ID with permission enforcement.
46     *
47     * @param int                     $documentId Document identifier.
48     * @param int                     $fileId     Attached file identifier.
49     * @param PermissionContext|null  $context    Actor permission context.
50     * @return ResponseInterface File download response or error response.
51     */
52    public function actionDownload(
53        int $documentId,
54        int $fileId,
55        ?PermissionContext $context = null
56    ): ResponseInterface {
57        $accessError = $this->checkDocumentAccess($documentId, $context);
58        if ($accessError !== null) {
59            return $accessError;
60        }
61
62        $file = $this->uploadService->getFile($documentId, $fileId);
63        $absPath = $file !== null ? $this->resolveFileAbsolutePath($file->filePath) : null;
64        if ($file === null || $absPath === null) {
65            return $this->textResponse(404, 'File not found or missing on storage server.');
66        }
67
68        return $this->streamFileResponse($file, $absPath);
69    }
70
71    private function checkDocumentAccess(int $documentId, ?PermissionContext $context): ?ResponseInterface
72    {
73        if ($context !== null && !$context->isAuthenticated()) {
74            return $this->textResponse(401, 'Unauthorized: Authentication required.');
75        }
76
77        if ($this->crudService !== null && $context !== null) {
78            try {
79                $this->crudService->read('documents', $documentId, $context);
80            } catch (Throwable $e) {
81                $status = $e instanceof PermissionDeniedException ? 403 : 404;
82                $msg = $status === 403 ? 'Forbidden: Insufficient permissions.' : 'Document not found.';
83                return $this->textResponse($status, $msg);
84            }
85        }
86
87        return null;
88    }
89
90    private function resolveFileAbsolutePath(string $relativePath): ?string
91    {
92        return SafeStoragePathResolver::resolve($this->uploadService->resolveStorageBase(), $relativePath);
93    }
94
95    private function streamFileResponse(DocumentFile $file, string $absPath): ResponseInterface
96    {
97        $handle = fopen($absPath, 'rb');
98        if ($handle === false) {
99            return $this->textResponse(500, 'Unable to open file resource.');
100        }
101
102        $stream = Stream::create($handle);
103        $contentDisposition = $this->buildContentDisposition($file->fileName);
104
105        return new Response(200, [
106            'Content-Type'           => $file->mimeType ?: 'application/octet-stream',
107            'Content-Disposition'    => $contentDisposition,
108            'Content-Length'         => (string) filesize($absPath),
109            'Cache-Control'          => 'private, no-cache, no-store, must-revalidate',
110            'Pragma'                 => 'no-cache',
111            'Expires'                => '0',
112            'X-Content-Type-Options' => 'nosniff',
113        ], $stream);
114    }
115
116    /**
117     * Builds RFC 5987 / RFC 6266 compliant Content-Disposition header resilient to CRLF injection.
118     */
119    private function buildContentDisposition(string $fileName): string
120    {
121        $stripped = str_replace(["\r", "\n", "\0", '"'], '', $fileName);
122        $ascii = preg_replace('/[^a-zA-Z0-9_\.\-]/', '_', $stripped);
123        $fallback = ($ascii !== null && $ascii !== '') ? $ascii : 'download.bin';
124        $encoded = rawurlencode($stripped !== '' ? $stripped : 'download.bin');
125
126        return sprintf('attachment; filename="%s"; filename*=UTF-8\'\'%s', $fallback, $encoded);
127    }
128
129    private function textResponse(int $status, string $message): ResponseInterface
130    {
131        return new Response($status, ['Content-Type' => self::CONTENT_TYPE_PLAIN], $message);
132    }
133}