Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.33% covered (success)
96.33%
210 / 218
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
DocumentUploadService
96.31% covered (success)
96.31%
209 / 217
64.29% covered (warning)
64.29%
9 / 14
47
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
 getSettings
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 validateFile
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 attachFileToDocument
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
5
 validateMagicBytesAndSanitize
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 detectMagicMime
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 bulkCreateDocumentsFromFiles
98.21% covered (success)
98.21%
55 / 56
0.00% covered (danger)
0.00%
0 / 1
6
 getAttachedFiles
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 getFile
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 deleteFile
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 resolveStorageBase
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 resolveStorageDir
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 generateUniqueFileName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 copyOrMoveFile
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Path\SafeStoragePathResolver;
12use App\Core\Security\Sanitizer\SvgXmlSanitizerService;
13use App\Core\Security\Sanitizer\SvgXmlSanitizerServiceInterface;
14use App\Modules\Documents\Domain\Exception\DocumentNotFoundException;
15use App\Modules\Documents\Domain\Exception\FileSizeExceededException;
16use App\Modules\Documents\Domain\Exception\InvalidExtensionException;
17use App\Modules\Documents\Domain\Model\DocumentFile;
18use InvalidArgumentException;
19use PDO;
20
21/**
22 * Service orchestrating file validation, storage, and mass document generation.
23 *
24 * @package App\Modules\Documents\Application\Service
25 */
26final readonly class DocumentUploadService
27{
28    private const string TABLE_SETTINGS = 'a_core_settings_records';
29    private const string TABLE_DOCUMENTS = 'c_mod_documents_records';
30    private const string TABLE_FILES = 'c_mod_document_files_records';
31
32    private const string DEFAULT_EXTENSIONS = 'pdf,doc,docx,xls,xlsx,ppt,pptx,odt,ods,odp,rtf,txt,csv,' .
33        'json,xml,zip,7z,tar,gz,png,jpg,jpeg,webp,svg,gif';
34    private const string DATE_FORMAT = 'Y-m-d H:i:s';
35
36    private SvgXmlSanitizerServiceInterface $sanitizer;
37
38    /**
39     * DocumentUploadService constructor.
40     *
41     * @param PDO                                  $pdo               Database PDO connection.
42     * @param DocumentVersioningService            $versioningService Versioning domain service.
43     * @param string|null                          $storagePath       Optional custom root storage path.
44     * @param SvgXmlSanitizerServiceInterface|null $sanitizer         Optional SVG/XML sanitizer service.
45     */
46    public function __construct(
47        private PDO $pdo,
48        private DocumentVersioningService $versioningService,
49        private ?string $storagePath = null,
50        ?SvgXmlSanitizerServiceInterface $sanitizer = null
51    ) {
52        $this->sanitizer = $sanitizer ?? new SvgXmlSanitizerService();
53    }
54
55    /**
56     * Retrieves current document configuration parameters from database.
57     *
58     * @return array{allowed_extensions: array<string>, allow_other: bool, max_size_mb: int}
59     */
60    public function getSettings(): array
61    {
62        $stmt = $this->pdo->prepare(
63            'SELECT setting_key, setting_value FROM ' . self::TABLE_SETTINGS . ' ' .
64            'WHERE category = :cat AND setting_key IN (' .
65            "'documents_allowed_extensions', 'documents_allow_other_extensions', 'documents_max_file_size_mb')"
66        );
67        $stmt->execute([':cat' => 'documents']);
68        $rows = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
69
70        $extRaw = (string) ($rows['documents_allowed_extensions'] ?? self::DEFAULT_EXTENSIONS);
71        $allowed = array_values(array_filter(array_map('trim', explode(',', strtolower($extRaw)))));
72
73        $allowOther = (string) ($rows['documents_allow_other_extensions'] ?? '0') === '1';
74        $maxMb = max(1, (int) ($rows['documents_max_file_size_mb'] ?? 50));
75
76        return [
77            'allowed_extensions' => $allowed,
78            'allow_other'        => $allowOther,
79            'max_size_mb'        => $maxMb,
80        ];
81    }
82
83    /**
84     * Validates file size and extension against system configuration parameters.
85     *
86     * @param string $fileName  Original uploaded file name.
87     * @param int    $fileSize  File size in bytes.
88     * @throws FileSizeExceededException If file size exceeds maximum permitted MB.
89     * @throws InvalidExtensionException If file extension is not allowed.
90     */
91    public function validateFile(string $fileName, int $fileSize): void
92    {
93        $settings = $this->getSettings();
94
95        $maxBytes = $settings['max_size_mb'] * 1048576;
96        if ($fileSize > $maxBytes) {
97            throw FileSizeExceededException::forSize($fileSize, $settings['max_size_mb']);
98        }
99
100        $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
101        if ($ext === '' || (!$settings['allow_other'] && !in_array($ext, $settings['allowed_extensions'], true))) {
102            throw InvalidExtensionException::forExtension($ext, $settings['allowed_extensions']);
103        }
104    }
105
106    /**
107     * Attaches an uploaded file to an existing document.
108     *
109     * @param string   $tempPath   Temporary file location on disk.
110     * @param string   $clientName Original client filename.
111     * @param int      $fileSize   File size in bytes.
112     * @param string   $mimeType   MIME content type.
113     * @param int      $documentId Target document identifier.
114     * @param int|null $versionId  Optional version milestone link.
115     * @param int      $userId     Uploader user identifier.
116     * @return DocumentFile Attached file domain entity.
117     */
118    public function attachFileToDocument(
119        string $tempPath,
120        string $clientName,
121        int $fileSize,
122        string $mimeType,
123        int $documentId,
124        ?int $versionId = null,
125        int $userId = 1
126    ): DocumentFile {
127        $this->validateFile($clientName, $fileSize);
128        $this->validateMagicBytesAndSanitize($tempPath, $clientName);
129
130        $ext = strtolower(pathinfo($clientName, PATHINFO_EXTENSION));
131        $effectiveSize = ($ext === 'svg' || $ext === 'xml') && file_exists($tempPath)
132            ? (int) filesize($tempPath)
133            : $fileSize;
134
135        $docStmt = $this->pdo->prepare('SELECT id, extension FROM ' . self::TABLE_DOCUMENTS . ' WHERE id = :id');
136        $docStmt->execute([':id' => $documentId]);
137        $doc = $docStmt->fetch(PDO::FETCH_ASSOC);
138        if ($doc === false) {
139            throw DocumentNotFoundException::forId($documentId);
140        }
141
142        $ext = strtolower(pathinfo($clientName, PATHINFO_EXTENSION));
143        $storageDir = $this->resolveStorageDir($documentId);
144        $safeName = $this->generateUniqueFileName($clientName);
145        $destination = $storageDir . DIRECTORY_SEPARATOR . $safeName;
146
147        $this->copyOrMoveFile($tempPath, $destination);
148
149        $relativePath = 'documents/' . $documentId . '/' . $safeName;
150
151        $stmt = $this->pdo->prepare(
152            'INSERT INTO ' . self::TABLE_FILES . ' ' .
153            '(document_id, version_id, file_name, file_path, file_size, ' .
154            'file_extension, mime_type, sort_order, created_by, created_at) ' .
155            'VALUES (:did, :vid, :fname, :fpath, :fsize, :ext, :mime, :sort, :cby, :cat)'
156        );
157        $stmt->execute([
158            ':did'   => $documentId,
159            ':vid'   => $versionId,
160            ':fname' => $clientName,
161            ':fpath' => $relativePath,
162            ':fsize' => $effectiveSize,
163            ':ext'   => $ext,
164            ':mime'  => $mimeType,
165            ':sort'  => 0,
166            ':cby'   => $userId,
167            ':cat'   => date(self::DATE_FORMAT),
168        ]);
169
170        $fileId = (int) $this->pdo->lastInsertId();
171
172        return new DocumentFile(
173            $fileId,
174            $documentId,
175            $versionId,
176            $clientName,
177            $relativePath,
178            $effectiveSize,
179            $ext,
180            $mimeType,
181            0,
182            $userId,
183            date(self::DATE_FORMAT)
184        );
185    }
186
187    private const array FORBIDDEN_MIMES = [
188        'application/x-dosexec',
189        'application/x-executable',
190        'application/x-sharedlib',
191        'text/x-php',
192        'application/x-httpd-php',
193        'text/x-shellscript',
194        'application/x-sh',
195    ];
196
197    /**
198     * Validates file magic bytes to block executable binaries and sanitizes SVG/XML content.
199     */
200    private function validateMagicBytesAndSanitize(string $tempPath, string $clientName): void
201    {
202        if (!file_exists($tempPath)) {
203            return;
204        }
205
206        $detectedMime = $this->detectMagicMime($tempPath);
207        foreach (self::FORBIDDEN_MIMES as $badMime) {
208            if (str_contains($detectedMime, $badMime)) {
209                throw new InvalidArgumentException(
210                    sprintf('Dangerous file payload rejected: detected MIME %s.', $detectedMime)
211                );
212            }
213        }
214
215        $ext = strtolower(pathinfo($clientName, PATHINFO_EXTENSION));
216        if ($ext === 'svg' || $ext === 'xml' || str_contains($detectedMime, 'xml')) {
217            $this->sanitizer->sanitizeFile($tempPath);
218        }
219    }
220
221    /**
222     * Accurately probes magic bytes using PHP fileinfo.
223     */
224    private function detectMagicMime(string $path): string
225    {
226        if (function_exists('finfo_open')) {
227            $finfo = finfo_open(FILEINFO_MIME_TYPE);
228            if ($finfo !== false) {
229                $mime = finfo_file($finfo, $path);
230                finfo_close($finfo);
231                if (is_string($mime) && $mime !== '') {
232                    return strtolower($mime);
233                }
234            }
235        }
236
237        return 'application/octet-stream';
238    }
239
240    /**
241     * Automatically creates independent Document records from an array of uploaded files.
242     * The user does not need to manually fill out any form fields.
243     *
244     * @param array<array{
245     *     tmp_name: string,
246     *     name: string,
247     *     size: int,
248     *     type?: string
249     * }>       $files     List of uploaded file descriptors.
250     * @param int $userId    Creating user identifier.
251     * @param int|null $companyId Optional related company ID.
252     * @param int|null $projectId Optional related project ID.
253     * @return array<array{document_id: int, file_id: int, name: string}> List of created records.
254     */
255    public function bulkCreateDocumentsFromFiles(
256        array $files,
257        int $userId = 1,
258        ?int $companyId = null,
259        ?int $projectId = null
260    ): array {
261        $created = [];
262
263        foreach ($files as $f) {
264            $tmpPath = (string) ($f['tmp_name'] ?? '');
265            $rawName = (string) ($f['name'] ?? 'Dokument');
266            $size = (int) ($f['size'] ?? 0);
267            $mime = (string) ($f['type'] ?? 'application/octet-stream');
268
269            if ($tmpPath === '' || !file_exists($tmpPath) || $size === 0) {
270                continue;
271            }
272
273            $this->validateFile($rawName, $size);
274
275            $ext = strtolower(pathinfo($rawName, PATHINFO_EXTENSION));
276            $cleanName = pathinfo($rawName, PATHINFO_FILENAME);
277            if (trim($cleanName) === '') {
278                $cleanName = $rawName;
279            }
280
281            $now = date(self::DATE_FORMAT);
282            $stmt = $this->pdo->prepare(
283                'INSERT INTO ' . self::TABLE_DOCUMENTS . ' ' .
284                '(document_name, document_type, document_status, extension, ' .
285                'current_version, company_id, project_id, is_active, special_access, ' .
286                'created_by, owner, created_at, updated_at) ' .
287                "VALUES (:name, 'file', 'draft', :ext, '1.0', :cid, :pid, 1, 1, :cby, :owner, :cat, :uat)"
288            );
289            $stmt->execute([
290                ':name'  => $cleanName,
291                ':ext'   => $ext,
292                ':cid'   => $companyId,
293                ':pid'   => $projectId,
294                ':cby'   => $userId,
295                ':owner' => $userId,
296                ':cat'   => $now,
297                ':uat'   => $now,
298            ]);
299
300            $docId = (int) $this->pdo->lastInsertId();
301
302            $attached = $this->attachFileToDocument(
303                $tmpPath,
304                $rawName,
305                $size,
306                $mime,
307                $docId,
308                null,
309                $userId
310            );
311
312            $this->versioningService->createInitialVersion($docId, [
313                'document_name'   => $cleanName,
314                'document_type'   => 'file',
315                'document_status' => 'draft',
316                'extension'       => $ext,
317                'current_version' => '1.0',
318                'company_id'      => $companyId,
319                'project_id'      => $projectId,
320                'primary_file'    => $rawName,
321            ], $userId);
322
323            $created[] = [
324                'document_id' => $docId,
325                'file_id'     => $attached->id,
326                'name'        => $cleanName,
327            ];
328        }
329
330        return $created;
331    }
332
333    /**
334     * Fetches all attached files for a given document.
335     *
336     * @param int $documentId Target document identifier.
337     * @return array<DocumentFile> List of files ordered by sort_order and ID.
338     */
339    public function getAttachedFiles(int $documentId): array
340    {
341        $stmt = $this->pdo->prepare(
342            'SELECT id, document_id, version_id, file_name, file_path, file_size, ' .
343            'file_extension, mime_type, sort_order, created_by, created_at ' .
344            'FROM ' . self::TABLE_FILES . ' WHERE document_id = :did ORDER BY sort_order ASC, id ASC'
345        );
346        $stmt->execute([':did' => $documentId]);
347        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
348
349        $results = [];
350        foreach ($rows as $r) {
351            $results[] = new DocumentFile(
352                (int) $r['id'],
353                (int) $r['document_id'],
354                $r['version_id'] !== null ? (int) $r['version_id'] : null,
355                (string) $r['file_name'],
356                (string) $r['file_path'],
357                (int) $r['file_size'],
358                (string) $r['file_extension'],
359                (string) $r['mime_type'],
360                (int) $r['sort_order'],
361                (int) $r['created_by'],
362                (string) $r['created_at']
363            );
364        }
365
366        return $results;
367    }
368
369    /**
370     * Retrieves single document file by document and file ID.
371     *
372     * @param int $documentId Document identifier.
373     * @param int $fileId     File record identifier.
374     * @return DocumentFile|null Domain entity or null if not found.
375     */
376    public function getFile(int $documentId, int $fileId): ?DocumentFile
377    {
378        $stmt = $this->pdo->prepare(
379            'SELECT id, document_id, version_id, file_name, file_path, file_size, ' .
380            'file_extension, mime_type, sort_order, created_by, created_at ' .
381            'FROM ' . self::TABLE_FILES . ' WHERE id = :fid AND document_id = :did'
382        );
383        $stmt->execute([':fid' => $fileId, ':did' => $documentId]);
384        $r = $stmt->fetch(PDO::FETCH_ASSOC);
385        if ($r === false) {
386            return null;
387        }
388
389        return new DocumentFile(
390            (int) $r['id'],
391            (int) $r['document_id'],
392            $r['version_id'] !== null ? (int) $r['version_id'] : null,
393            (string) $r['file_name'],
394            (string) $r['file_path'],
395            (int) $r['file_size'],
396            (string) $r['file_extension'],
397            (string) $r['mime_type'],
398            (int) $r['sort_order'],
399            (int) $r['created_by'],
400            (string) $r['created_at']
401        );
402    }
403
404    /**
405     * Removes an attached file from disk and database record.
406     *
407     * @param int $documentId Target document ID.
408     * @param int $fileId     File ID to delete.
409     * @return bool True if successfully deleted.
410     */
411    public function deleteFile(int $documentId, int $fileId): bool
412    {
413        $file = $this->getFile($documentId, $fileId);
414        if ($file === null) {
415            return false;
416        }
417
418        $baseDir = $this->resolveStorageBase();
419        $absPath = SafeStoragePathResolver::resolve($baseDir, $file->filePath);
420        if ($absPath !== null && file_exists($absPath)) {
421            @unlink($absPath);
422        }
423
424        $stmt = $this->pdo->prepare('DELETE FROM ' . self::TABLE_FILES . ' WHERE id = :id');
425        $stmt->execute([':id' => $fileId]);
426
427        return true;
428    }
429
430    /**
431     * Resolves absolute base storage directory for documents.
432     *
433     * @return string Absolute directory path.
434     */
435    public function resolveStorageBase(): string
436    {
437        if ($this->storagePath !== null) {
438            return rtrim($this->storagePath, '/\\');
439        }
440
441        return dirname(__DIR__, 5) . DIRECTORY_SEPARATOR . 'storage';
442    }
443
444    /**
445     * Resolves or creates document storage subdirectory.
446     *
447     * @param int $documentId Document ID.
448     * @return string Directory path on disk.
449     */
450    private function resolveStorageDir(int $documentId): string
451    {
452        $base = $this->resolveStorageBase();
453        $dir = $base . DIRECTORY_SEPARATOR . 'documents' . DIRECTORY_SEPARATOR . $documentId;
454        if (!is_dir($dir)) {
455            mkdir($dir, 0755, true);
456        }
457
458        return $dir;
459    }
460
461    /**
462     * Generates a collision-resistant safe filename for disk storage.
463     *
464     * @param string $originalName Original filename.
465     * @return string Normalized unique filename.
466     */
467    private function generateUniqueFileName(string $originalName): string
468    {
469        $hash = substr(sha1(microtime() . $originalName . random_bytes(8)), 0, 12);
470        $clean = preg_replace('/[^a-zA-Z0-9._-]/', '_', $originalName);
471
472        return $hash . '_' . $clean;
473    }
474
475    /**
476     * Moves or copies uploaded file safely into final location.
477     *
478     * @param string $source Temporary path.
479     * @param string $dest   Destination path.
480     */
481    private function copyOrMoveFile(string $source, string $dest): void
482    {
483        if (is_uploaded_file($source)) {
484            move_uploaded_file($source, $dest);
485        } else {
486            copy($source, $dest);
487        }
488    }
489}