Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.30% covered (success)
91.30%
63 / 69
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentAttachmentService
91.18% covered (success)
91.18%
62 / 68
62.50% covered (warning)
62.50%
5 / 8
31.66
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
 validateFile
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 validateMimeContent
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 storeAttachment
97.50% covered (success)
97.50%
39 / 40
0.00% covered (danger)
0.00%
0 / 1
7
 resolveAbsolutePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveStorageBase
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 moveOrCopyFile
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 sanitizeClientFileName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Path\SafeStoragePathResolver;
12use App\Modules\Comments\Domain\Exception\CommentAttachmentException;
13use App\Modules\Comments\Domain\Model\CommentAttachment;
14use App\Modules\Comments\Domain\Repository\CommentRepositoryInterface;
15
16/**
17 * Service handling secure attachment processing, validation and storage (OWASP ASVS 5 / NIST).
18 *
19 * @package App\Modules\Comments\Application\Service
20 */
21final readonly class CommentAttachmentService implements CommentAttachmentServiceInterface
22{
23    private const int MAX_FILE_SIZE_MB = 15;
24    private const array ALLOWED_EXTENSIONS = [
25        'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg',
26        'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
27        'odt', 'ods', 'txt', 'csv', 'zip', '7z',
28    ];
29    private const array DISALLOWED_MIME_PATTERNS = [
30        'php', 'exec', 'perl', 'shell', 'javascript', 'x-sh',
31    ];
32
33    /**
34     * CommentAttachmentService constructor.
35     */
36    public function __construct(
37        private CommentRepositoryInterface $repository,
38        private ?string $storageBasePath = null
39    ) {
40    }
41
42    /**
43     * Validates file size, extension and content MIME signature.
44     *
45     * @throws CommentAttachmentException If validation fails.
46     */
47    public function validateFile(string $fileName, int $fileSize, ?string $tmpPath = null): void
48    {
49        $maxBytes = self::MAX_FILE_SIZE_MB * 1048576;
50        if ($fileSize > $maxBytes || $fileSize <= 0) {
51            throw CommentAttachmentException::forExceededSize($fileSize, self::MAX_FILE_SIZE_MB);
52        }
53
54        $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
55        if ($ext === '' || !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
56            throw CommentAttachmentException::forDisallowedExtension($ext);
57        }
58
59        if ($tmpPath !== null && file_exists($tmpPath)) {
60            $this->validateMimeContent($tmpPath, $ext);
61        }
62    }
63
64    /**
65     * Inspects binary buffer to guarantee file is not an executable or dangerous script.
66     */
67    private function validateMimeContent(string $tmpPath, string $ext): void
68    {
69        $finfo = finfo_open(FILEINFO_MIME_TYPE);
70        $mime = $finfo !== false ? (string) finfo_file($finfo, $tmpPath) : '';
71        if ($finfo !== false) {
72            finfo_close($finfo);
73        }
74
75        $lowerMime = strtolower($mime);
76        foreach (self::DISALLOWED_MIME_PATTERNS as $pattern) {
77            if (str_contains($lowerMime, $pattern)) {
78                throw CommentAttachmentException::forDisallowedExtension($ext);
79            }
80        }
81    }
82
83    /**
84     * Stores an uploaded attachment file securely on disk and persists metadata.
85     */
86    public function storeAttachment(
87        string $tmpPath,
88        string $clientName,
89        int $fileSize,
90        int $commentId,
91        int $userId
92    ): CommentAttachment {
93        $this->validateFile($clientName, $fileSize, $tmpPath);
94
95        $ext = strtolower(pathinfo($clientName, PATHINFO_EXTENSION));
96        $safeDiskName = bin2hex(random_bytes(16)) . '.' . $ext;
97        $token = bin2hex(random_bytes(24));
98        $subDir = 'comments' . DIRECTORY_SEPARATOR . $commentId;
99        $storageDir = $this->resolveStorageBase() . DIRECTORY_SEPARATOR . $subDir;
100
101        if (!is_dir($storageDir) && !mkdir($storageDir, 0755, true) && !is_dir($storageDir)) {
102            throw CommentAttachmentException::forStorageFailure('Directory could not be created');
103        }
104
105        $destination = $storageDir . DIRECTORY_SEPARATOR . $safeDiskName;
106        $this->moveOrCopyFile($tmpPath, $destination);
107
108        $finfo = finfo_open(FILEINFO_MIME_TYPE);
109        $mime = $finfo !== false ? (string) finfo_file($finfo, $destination) : 'application/octet-stream';
110        if ($finfo !== false) {
111            finfo_close($finfo);
112        }
113
114        $relativePath = 'comments/' . $commentId . '/' . $safeDiskName;
115        $attachment = new CommentAttachment(
116            id:            0,
117            commentId:     $commentId,
118            fileName:      $this->sanitizeClientFileName($clientName),
119            filePath:      $relativePath,
120            fileSize:      $fileSize,
121            fileExtension: $ext,
122            mimeType:      $mime ?: 'application/octet-stream',
123            token:         $token,
124            createdBy:     $userId,
125            createdAt:     date('Y-m-d H:i:s')
126        );
127
128        $newId = $this->repository->saveAttachment($attachment);
129
130        return new CommentAttachment(
131            id:            $newId,
132            commentId:     $commentId,
133            fileName:      $attachment->fileName,
134            filePath:      $attachment->filePath,
135            fileSize:      $attachment->fileSize,
136            fileExtension: $attachment->fileExtension,
137            mimeType:      $attachment->mimeType,
138            token:         $attachment->token,
139            createdBy:     $attachment->createdBy,
140            createdAt:     $attachment->createdAt
141        );
142    }
143
144    /**
145     * Resolves absolute storage path from relative path.
146     */
147    public function resolveAbsolutePath(string $relativePath): ?string
148    {
149        return SafeStoragePathResolver::resolve($this->resolveStorageBase(), $relativePath);
150    }
151
152    /**
153     * Resolves root storage base directory.
154     */
155    public function resolveStorageBase(): string
156    {
157        if ($this->storageBasePath !== null && $this->storageBasePath !== '') {
158            return rtrim($this->storageBasePath, '\\/');
159        }
160
161        return dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'storage';
162    }
163
164    private function moveOrCopyFile(string $source, string $destination): void
165    {
166        if (is_uploaded_file($source)) {
167            if (!move_uploaded_file($source, $destination)) {
168                throw CommentAttachmentException::forStorageFailure('Upload relocation failed');
169            }
170            return;
171        }
172
173        if (!copy($source, $destination)) {
174            throw CommentAttachmentException::forStorageFailure('File copy failed');
175        }
176    }
177
178    private function sanitizeClientFileName(string $name): string
179    {
180        $clean = preg_replace('/[^\p{L}\p{N}\._\- ]/u', '_', basename($name));
181        return $clean !== null && $clean !== '' ? $clean : 'attachment';
182    }
183}