Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.74% covered (success)
94.74%
18 / 19
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
DownloadMyUploadAction
94.74% covered (success)
94.74%
18 / 19
66.67% covered (warning)
66.67%
2 / 3
5.00
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
 __invoke
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 sanitiseAttachmentFilename
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Document\Submission;
6
7use App\Domain\Document\Submission\Service\InvestorDocumentService;
8use App\Domain\Exception\ForbiddenException;
9use App\Support\Row;
10use Psr\Http\Message\ResponseInterface;
11use Psr\Http\Message\ServerRequestInterface;
12
13/**
14 * Stream the investor's own uploaded file back to them.
15 *
16 * Files live outside the webroot — the only way out is through this
17 * authenticated action. Ownership is checked before the storage layer
18 * is touched. The Content-Disposition is forced to attachment so a PDF
19 * (or anything else) cannot be rendered inline as a same-origin asset.
20 */
21final readonly class DownloadMyUploadAction
22{
23    public function __construct(
24        private InvestorDocumentService $service,
25    ) {}
26
27    /**
28     * @param array<string, string> $args
29     * @param ServerRequestInterface $request
30     * @param ResponseInterface $response
31     */
32    public function __invoke(
33        ServerRequestInterface $request,
34        ResponseInterface $response,
35        array $args,
36    ): ResponseInterface {
37        $investorId = Row::nullableInt($request->getAttributes(), 'investorId');
38        if ($investorId === null) {
39            throw new ForbiddenException('This endpoint requires an investor account.');
40        }
41
42        $documentId = (int)$args['id'];
43
44        $event = $this->service->getCurrentFileForInvestor($investorId, $documentId);
45        $storageKey = (string)$event->fileStorageKey;
46        $stream = $this->service->getStream($storageKey);
47
48        $filename = self::sanitiseAttachmentFilename($event->originalFilename ?? 'document');
49        $mimeType = $event->mimeType ?? 'application/octet-stream';
50
51        return $response
52            ->withHeader('Content-Type', $mimeType)
53            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
54            ->withHeader('Cache-Control', 'private, no-store')
55            ->withHeader('X-Content-Type-Options', 'nosniff')
56            ->withBody($stream);
57    }
58
59    private static function sanitiseAttachmentFilename(string $name): string
60    {
61        $clean = preg_replace('/[^A-Za-z0-9 ._\-]/', '_', $name) ?? 'document';
62        $clean = trim($clean);
63
64        return $clean === '' ? 'document' : $clean;
65    }
66}