• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LeTraceurSnorkLibrary / MessSaga / 23887791849

02 Apr 2026 06:44AM UTC coverage: 30.982% (-0.7%) from 31.674%
23887791849

Pull #15

github

web-flow
Merge a8f9b6bfc into a7d6f3637
Pull Request #15: feat: add s3 as storage

30 of 105 new or added lines in 13 files covered. (28.57%)

4 existing lines in 4 files now uncovered.

448 of 1446 relevant lines covered (30.98%)

0.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.98
/app/Services/Media/ImportedMediaResolverService.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace App\Services\Media;
6

7
use App\Services\Media\Storage\MediaStorageInterface;
8
use App\Support\FilenameSanitizer;
9
use Illuminate\Support\Facades\Log;
10

11
class ImportedMediaResolverService
12
{
13
    /**
14
     * Кэш индексов файлов по корню распакованного экспорта:
15
     * [rootPath => [sanitizedLowerBasename => [absolutePath1, absolutePath2...]]]
16
     *
17
     * @var array<string, array<string, array<int, string>>>
18
     */
19
    private array $basenameIndexCache = [];
20

21
    /**
22
     * @param MediaStorageInterface $mediaStorage
23
     */
24
    public function __construct(
25
        private readonly MediaStorageInterface $mediaStorage
26
    ) {
NEW
27
    }
×
28

29
    /**
30
     * Копирует медиа-файл из временно распакованного экспорта
31
     * в постоянное хранилище переписки (без привязки к конкретному сообщению).
32
     *
33
     * @see self::resolveSource() Логика безопасного резолва исходного файла.
34
     *
35
     * @param string $attachmentExportPath Путь к вложению из export-данных сообщения.
36
     * @param int    $conversationId       Идентификатор переписки.
37
     *
38
     * @param string $mediaRootPath        Абсолютный путь до корня распакованного экспорта.
39
     *
40
     * @return string|null Относительный путь в Storage при успехе, иначе null.
41
     */
42
    public function copyForConversation(
43
        string $mediaRootPath,
44
        string $attachmentExportPath,
45
        int $conversationId,
46
    ): ?string {
47
        $resolved = $this->resolveSource($mediaRootPath, $attachmentExportPath);
2✔
48
        if ($resolved === null) {
2✔
49
            return null;
1✔
50
        }
51

52
        $storedRelative = sprintf('conversations/%d/media/%s', $conversationId, $resolved['basename']);
1✔
53
        if (!$this->storeStream($resolved['source'], $storedRelative)) {
1✔
54
            return null;
×
55
        }
56

57
        return $storedRelative;
1✔
58
    }
59

60
    /**
61
     * Копирует медиа-файл из временно распакованного экспорта
62
     * в постоянное хранилище переписки с привязкой к конкретному сообщению.
63
     *
64
     * @see self::resolveSource() Логика безопасного резолва исходного файла.
65
     *
66
     * @param string $attachmentExportPath Путь к вложению из export-данных сообщения.
67
     * @param int    $conversationId       Идентификатор переписки.
68
     * @param int    $messageId            Идентификатор сообщения.
69
     *
70
     * @param string $mediaRootPath        Абсолютный путь до корня распакованного экспорта.
71
     *
72
     * @return string|null Относительный путь в Storage при успехе, иначе null.
73
     */
74
    public function copyForMessage(
75
        string $mediaRootPath,
76
        string $attachmentExportPath,
77
        int $conversationId,
78
        int $messageId
79
    ): ?string {
80
        $resolved = $this->resolveSource($mediaRootPath, $attachmentExportPath);
2✔
81
        if ($resolved === null) {
2✔
82
            return null;
1✔
83
        }
84

85
        $storedRelative = sprintf('conversations/%d/media/%d/%s', $conversationId, $messageId, $resolved['basename']);
1✔
86
        if (!$this->storeStream($resolved['source'], $storedRelative)) {
1✔
87
            return null;
×
88
        }
89

90
        return $storedRelative;
1✔
91
    }
92

93
    /**
94
     * Разрешает исходный файл вложения в распакованном экспорте.
95
     * Сначала пытается найти файл по точному export_path,
96
     * затем использует fallback по basename для legacy-экспортов.
97
     *
98
     * @param string $mediaRootPath        Абсолютный путь до корня распакованного экспорта.
99
     * @param string $attachmentExportPath Путь к вложению из export-данных сообщения.
100
     *
101
     * @return array{
102
     *     source: string,
103
     *     basename: string
104
     * }|null
105
     */
106
    private function resolveSource(string $mediaRootPath, string $attachmentExportPath): ?array
107
    {
108
        $root = rtrim($mediaRootPath, DIRECTORY_SEPARATOR);
4✔
109

110
        $exactPath = $this->tryResolveByExportPath($root, $attachmentExportPath);
4✔
111
        if ($exactPath !== null) {
4✔
112
            return [
1✔
113
                'source' => $exactPath,
1✔
114
                'basename' => FilenameSanitizer::sanitize(basename(str_replace('\\', '/', $attachmentExportPath))),
1✔
115
            ];
1✔
116
        }
117

118
        foreach ($this->extractCandidateBasenames($attachmentExportPath) as $candidate) {
3✔
119
            $sanitizedCandidate = FilenameSanitizer::sanitize($candidate);
3✔
120
            if ($sanitizedCandidate === 'file') {
3✔
121
                continue;
×
122
            }
123
            $found = $this->findUniqueFileByBasename($root, $sanitizedCandidate);
3✔
124
            if ($found !== null) {
3✔
125
                return [
1✔
126
                    'source' => $found,
1✔
127
                    'basename' => $sanitizedCandidate,
1✔
128
                ];
1✔
129
            }
130
        }
131

132
        Log::debug('Import media file not found', [
2✔
133
            'export_path' => $attachmentExportPath,
2✔
134
            'basename' => basename(str_replace('\\', '/', $attachmentExportPath)),
2✔
135
            'root' => $mediaRootPath,
2✔
136
        ]);
2✔
137

138
        return null;
2✔
139
    }
140

141
    /**
142
     * Пытается разрешить вложение по точному пути из export-данных.
143
     * Выполняет базовые проверки безопасности пути и гарантирует,
144
     * что итоговый файл расположен внутри корня распакованного экспорта.
145
     *
146
     * @param string $root                 Абсолютный путь до корня распакованного экспорта.
147
     * @param string $attachmentExportPath Путь к вложению из export-данных сообщения.
148
     *
149
     * @return string|null Абсолютный путь к найденному файлу или null.
150
     */
151
    private function tryResolveByExportPath(string $root, string $attachmentExportPath): ?string
152
    {
153
        $relativePath = trim(str_replace('\\', '/', $attachmentExportPath));
4✔
154
        if ($relativePath === '' || str_contains($relativePath, "\0")) {
4✔
155
            return null;
1✔
156
        }
157

158
        $relativePath = ltrim($relativePath, '/');
4✔
159
        $parts = explode('/', $relativePath);
4✔
160
        if (in_array('..', $parts, true)) {
4✔
161
            return null;
1✔
162
        }
163

164
        $candidate = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
4✔
165
        if (!is_file($candidate)) {
4✔
166
            return null;
2✔
167
        }
168

169
        $candidateReal = realpath($candidate);
2✔
170
        $rootReal = realpath($root);
2✔
171
        if ($candidateReal === false || $rootReal === false) {
2✔
172
            return null;
×
173
        }
174

175
        if ($candidateReal === $rootReal) {
2✔
176
            return $candidateReal;
×
177
        }
178

179
        $prefix = rtrim($rootReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
2✔
180

181
        return str_starts_with($candidateReal . DIRECTORY_SEPARATOR, $prefix)
2✔
182
            ? $candidateReal
2✔
183
            : null;
2✔
184
    }
185

186
    /**
187
     * Потоково записывает исходный файл в Storage по целевому относительному пути.
188
     * Используется вместо загрузки всего файла в память.
189
     *
190
     * @param string $sourcePath     Абсолютный путь к исходному файлу на диске.
191
     * @param string $storedRelative Относительный путь назначения в Storage.
192
     *
193
     * @return bool true, если файл успешно записан.
194
     */
195
    private function storeStream(string $sourcePath, string $storedRelative): bool
196
    {
197
        $stream = @fopen($sourcePath, 'rb');
1✔
198
        if (!is_resource($stream)) {
1✔
199
            return false;
×
200
        }
201

202
        try {
203
            return $this->mediaStorage->putStream($storedRelative, $stream);
1✔
204
        } finally {
205
            fclose($stream);
1✔
206
        }
207
    }
208

209
    /**
210
     * Формирует список кандидатов basename для legacy export_path без слешей.
211
     * Например, из "001_ABC_photo.jpg" дополнительно извлекается "ABC_photo.jpg" и "photo.jpg".
212
     *
213
     * @param string $attachmentExportPath Путь к вложению из export-данных сообщения.
214
     *
215
     * @return array<int, string> Уникальный список кандидатов basename в порядке приоритета.
216
     */
217
    private function extractCandidateBasenames(string $attachmentExportPath): array
218
    {
219
        $normalized = str_replace('\\', '/', $attachmentExportPath);
4✔
220
        $basename = basename($normalized);
4✔
221
        $candidates = [$basename];
4✔
222

223
        if (!str_contains($normalized, '/')) {
4✔
224
            $parts = explode('_', $normalized);
3✔
225
            for ($i = 1; $i < count($parts); $i++) {
3✔
226
                $suffix = implode('_', array_slice($parts, $i));
3✔
227
                if ($suffix !== '' && str_contains($suffix, '.')) {
3✔
228
                    $candidates[] = $suffix;
3✔
229
                }
230
            }
231
        }
232

233
        return array_values(array_unique($candidates));
4✔
234
    }
235

236
    /**
237
     * Ищет файл по basename рекурсивно в директории и возвращает путь,
238
     * только если найдено ровно одно совпадение.
239
     * При нескольких совпадениях возвращает null и пишет warning в лог.
240
     *
241
     * @param string $dir      Абсолютный путь до корневой директории поиска.
242
     * @param string $basename Искомое имя файла (или candidate из legacy-правила).
243
     *
244
     * @return string|null Абсолютный путь к единственному найденному файлу или null.
245
     */
246
    private function findUniqueFileByBasename(string $dir, string $basename): ?string
247
    {
248
        if (!is_dir($dir)) {
4✔
249
            return null;
1✔
250
        }
251

252
        $target = strtolower(FilenameSanitizer::sanitize($basename));
4✔
253
        if ($target === '' || $target === 'file') {
4✔
254
            return null;
1✔
255
        }
256

257
        $index = $this->getBasenameIndex($dir);
4✔
258
        $matches = $index[$target] ?? [];
4✔
259

260
        if (count($matches) === 1) {
4✔
261
            return $matches[0];
2✔
262
        }
263
        if (count($matches) > 1) {
3✔
264
            Log::warning('Import media file match is ambiguous', [
2✔
265
                'basename' => $basename,
2✔
266
                'root' => $dir,
2✔
267
                'count' => count($matches),
2✔
268
            ]);
2✔
269
        }
270

271
        return null;
3✔
272
    }
273

274
    /**
275
     * Строит (и кэширует) индекс файлов по basename.
276
     *
277
     * @param string $root
278
     *
279
     * @return array<string, array<int, string>>
280
     */
281
    private function getBasenameIndex(string $root): array
282
    {
283
        if (isset($this->basenameIndexCache[$root])) {
4✔
284
            return $this->basenameIndexCache[$root];
2✔
285
        }
286

287
        if (!is_dir($root)) {
4✔
288
            $this->basenameIndexCache[$root] = [];
×
289

290
            return $this->basenameIndexCache[$root];
×
291
        }
292

293
        $index = [];
4✔
294
        $stack = [$root];
4✔
295

296
        while ($stack !== []) {
4✔
297
            $scanDir = array_pop($stack);
4✔
298
            if (!is_string($scanDir)) {
4✔
299
                continue;
×
300
            }
301

302
            $items = @scandir($scanDir);
4✔
303
            if ($items === false) {
4✔
304
                continue;
×
305
            }
306

307
            foreach ($items as $item) {
4✔
308
                if ($item === '.' || $item === '..') {
4✔
309
                    continue;
4✔
310
                }
311

312
                $fullPath = $scanDir . DIRECTORY_SEPARATOR . $item;
4✔
313
                if (is_dir($fullPath)) {
4✔
314
                    $stack[] = $fullPath;
4✔
315
                    continue;
4✔
316
                }
317

318
                if (!is_file($fullPath)) {
4✔
319
                    continue;
×
320
                }
321

322
                $sanitized = strtolower(FilenameSanitizer::sanitize($item));
4✔
323
                if ($sanitized === '' || $sanitized === 'file') {
4✔
324
                    continue;
×
325
                }
326

327
                $index[$sanitized][] = $fullPath;
4✔
328
            }
329
        }
330

331
        $this->basenameIndexCache[$root] = $index;
4✔
332

333
        return $this->basenameIndexCache[$root];
4✔
334
    }
335
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc