Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
75.58% |
65 / 86 |
|
50.00% |
6 / 12 |
CRAP | |
0.00% |
0 / 1 |
| ProvidedDocumentService | |
75.58% |
65 / 86 |
|
50.00% |
6 / 12 |
69.92 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| uploadForInvestor | |
76.92% |
30 / 39 |
|
0.00% |
0 / 1 |
15.08 | |||
| listForInvestor | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getForInvestor | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| getForAdmin | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| getStream | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| delete | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
| sniffMime | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| hashStream | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
| rewindable | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| extractExtension | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| describeUploadError | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
72 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Domain\Document\Provided\Service; |
| 6 | |
| 7 | use App\Domain\Document\Provided\Data\ProvidedDocumentData; |
| 8 | use App\Domain\Document\Provided\Repository\ProvidedDocumentRepository; |
| 9 | use App\Domain\Exception\BadRequestException; |
| 10 | use App\Domain\Exception\NotFoundException; |
| 11 | use App\Domain\Storage\Service\FileStorageInterface; |
| 12 | use finfo; |
| 13 | use Psr\Http\Message\StreamInterface; |
| 14 | use Psr\Http\Message\UploadedFileInterface; |
| 15 | use Throwable; |
| 16 | |
| 17 | /** |
| 18 | * Admin-provides-a-document-to-an-investor workflow (FSC-57). |
| 19 | * |
| 20 | * Unlike investor uploads there's no review state machine: the admin uploads |
| 21 | * a titled file and it is immediately downloadable by the investor. The bytes |
| 22 | * go through the shared {@see FileStorageInterface}; the row carries the |
| 23 | * metadata. |
| 24 | */ |
| 25 | final readonly class ProvidedDocumentService |
| 26 | { |
| 27 | /** 25 MB — matches the investor-upload default cap. */ |
| 28 | private const int MAX_SIZE_BYTES = 26214400; |
| 29 | |
| 30 | /** @var list<string> */ |
| 31 | private const array ALLOWED_MIME_TYPES = [ |
| 32 | 'application/pdf', |
| 33 | 'image/png', |
| 34 | 'image/jpeg', |
| 35 | 'image/tiff', |
| 36 | 'application/msword', |
| 37 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', |
| 38 | 'application/vnd.ms-excel', |
| 39 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
| 40 | 'text/plain', |
| 41 | ]; |
| 42 | |
| 43 | public function __construct( |
| 44 | private ProvidedDocumentRepository $repository, |
| 45 | private FileStorageInterface $storage, |
| 46 | ) {} |
| 47 | |
| 48 | public function uploadForInvestor( |
| 49 | int $investorId, |
| 50 | int $userId, |
| 51 | string $title, |
| 52 | ?string $description, |
| 53 | UploadedFileInterface $file, |
| 54 | ): ProvidedDocumentData { |
| 55 | $cleanTitle = trim($title); |
| 56 | if ($cleanTitle === '') { |
| 57 | throw new BadRequestException('A title is required.'); |
| 58 | } |
| 59 | |
| 60 | $cleanDescription = $description !== null && trim($description) !== '' ? trim($description) : null; |
| 61 | |
| 62 | if ($file->getError() !== UPLOAD_ERR_OK) { |
| 63 | throw new BadRequestException(self::describeUploadError($file->getError())); |
| 64 | } |
| 65 | |
| 66 | $size = $file->getSize(); |
| 67 | if ($size === null || $size <= 0) { |
| 68 | throw new BadRequestException('Uploaded file is empty.'); |
| 69 | } |
| 70 | if ($size > self::MAX_SIZE_BYTES) { |
| 71 | throw new BadRequestException(sprintf('File too large. Maximum %d bytes allowed.', self::MAX_SIZE_BYTES)); |
| 72 | } |
| 73 | |
| 74 | $stream = $file->getStream(); |
| 75 | $sniffedMime = $this->sniffMime($stream); |
| 76 | if (!in_array($sniffedMime, self::ALLOWED_MIME_TYPES, true)) { |
| 77 | throw new BadRequestException(sprintf('File type %s is not allowed.', $sniffedMime)); |
| 78 | } |
| 79 | |
| 80 | $sha256 = $this->hashStream($stream); |
| 81 | $originalName = (string)$file->getClientFilename(); |
| 82 | $extension = self::extractExtension($originalName); |
| 83 | |
| 84 | $rewindable = $this->rewindable($stream); |
| 85 | $storageKey = $this->storage->store($rewindable, $extension); |
| 86 | |
| 87 | try { |
| 88 | $id = $this->repository->create( |
| 89 | investorId: $investorId, |
| 90 | title: $cleanTitle, |
| 91 | description: $cleanDescription, |
| 92 | fileStorageKey: $storageKey, |
| 93 | originalFilename: $originalName !== '' ? $originalName : 'document', |
| 94 | mimeType: $sniffedMime, |
| 95 | sizeBytes: $size, |
| 96 | sha256: $sha256, |
| 97 | uploadedByUserId: $userId, |
| 98 | ); |
| 99 | } catch (Throwable $e) { |
| 100 | // The DB row never landed — best-effort cleanup of the orphaned file. |
| 101 | try { |
| 102 | $this->storage->delete($storageKey); |
| 103 | } catch (Throwable) { |
| 104 | // ignore |
| 105 | } |
| 106 | throw $e; |
| 107 | } |
| 108 | |
| 109 | $reloaded = $this->repository->findById($id); |
| 110 | if ($reloaded === null) { |
| 111 | throw new NotFoundException('Document persisted but could not be reloaded.'); |
| 112 | } |
| 113 | |
| 114 | return $reloaded; |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * @param int $investorId |
| 119 | * @return list<ProvidedDocumentData> |
| 120 | */ |
| 121 | public function listForInvestor(int $investorId): array |
| 122 | { |
| 123 | return $this->repository->findByInvestorId($investorId); |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * Resolve a document for download, scoped to the owning investor. |
| 128 | * @param int $investorId |
| 129 | * @param int $providedDocumentId |
| 130 | */ |
| 131 | public function getForInvestor(int $investorId, int $providedDocumentId): ProvidedDocumentData |
| 132 | { |
| 133 | $document = $this->repository->findByIdForInvestor($providedDocumentId, $investorId); |
| 134 | if ($document === null) { |
| 135 | throw new NotFoundException('Document not found.'); |
| 136 | } |
| 137 | |
| 138 | return $document; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Resolve a document for download as an admin (no investor filter). |
| 143 | * @param int $providedDocumentId |
| 144 | */ |
| 145 | public function getForAdmin(int $providedDocumentId): ProvidedDocumentData |
| 146 | { |
| 147 | $document = $this->repository->findById($providedDocumentId); |
| 148 | if ($document === null) { |
| 149 | throw new NotFoundException('Document not found.'); |
| 150 | } |
| 151 | |
| 152 | return $document; |
| 153 | } |
| 154 | |
| 155 | public function getStream(string $storageKey): StreamInterface |
| 156 | { |
| 157 | return $this->storage->retrieve($storageKey); |
| 158 | } |
| 159 | |
| 160 | public function delete(int $providedDocumentId): void |
| 161 | { |
| 162 | $document = $this->repository->findById($providedDocumentId); |
| 163 | if ($document === null) { |
| 164 | throw new NotFoundException('Document not found.'); |
| 165 | } |
| 166 | |
| 167 | $this->repository->delete($providedDocumentId); |
| 168 | |
| 169 | // Best-effort file cleanup; the row (the source of truth) is already gone. |
| 170 | try { |
| 171 | $this->storage->delete($document->fileStorageKey); |
| 172 | } catch (Throwable) { |
| 173 | // ignore |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | private function sniffMime(StreamInterface $stream): string |
| 178 | { |
| 179 | if ($stream->isSeekable()) { |
| 180 | $stream->rewind(); |
| 181 | } |
| 182 | $head = $stream->read(8192); |
| 183 | |
| 184 | $finfo = new finfo(FILEINFO_MIME_TYPE); |
| 185 | $detected = $finfo->buffer($head); |
| 186 | |
| 187 | return is_string($detected) && $detected !== '' |
| 188 | ? $detected |
| 189 | : 'application/octet-stream'; |
| 190 | } |
| 191 | |
| 192 | private function hashStream(StreamInterface $stream): string |
| 193 | { |
| 194 | if ($stream->isSeekable()) { |
| 195 | $stream->rewind(); |
| 196 | } |
| 197 | |
| 198 | $ctx = hash_init('sha256'); |
| 199 | while (!$stream->eof()) { |
| 200 | $chunk = $stream->read(8192); |
| 201 | if ($chunk === '') { |
| 202 | break; |
| 203 | } |
| 204 | hash_update($ctx, $chunk); |
| 205 | } |
| 206 | |
| 207 | return hash_final($ctx); |
| 208 | } |
| 209 | |
| 210 | private function rewindable(StreamInterface $stream): StreamInterface |
| 211 | { |
| 212 | if ($stream->isSeekable()) { |
| 213 | $stream->rewind(); |
| 214 | } |
| 215 | |
| 216 | return $stream; |
| 217 | } |
| 218 | |
| 219 | private static function extractExtension(string $filename): string |
| 220 | { |
| 221 | if ($filename === '') { |
| 222 | return ''; |
| 223 | } |
| 224 | |
| 225 | return pathinfo($filename, PATHINFO_EXTENSION); |
| 226 | } |
| 227 | |
| 228 | private static function describeUploadError(int $error): string |
| 229 | { |
| 230 | return match ($error) { |
| 231 | UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File exceeds the upload size limit.', |
| 232 | UPLOAD_ERR_PARTIAL => 'The file was only partially uploaded.', |
| 233 | UPLOAD_ERR_NO_FILE => 'No file was uploaded.', |
| 234 | UPLOAD_ERR_NO_TMP_DIR => 'Server is missing a temp directory for uploads.', |
| 235 | UPLOAD_ERR_CANT_WRITE => 'Server failed to write the upload to disk.', |
| 236 | UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the upload.', |
| 237 | default => 'Upload failed.', |
| 238 | }; |
| 239 | } |
| 240 | } |