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

LeTraceurSnorkLibrary / MessSaga / 25921197134

15 May 2026 01:44PM UTC coverage: 41.107% (+4.5%) from 36.562%
25921197134

Pull #20

github

web-flow
Merge a78ef7498 into aac55fbb3
Pull Request #20: feat: tariffs

290 of 509 new or added lines in 31 files covered. (56.97%)

3 existing lines in 3 files now uncovered.

869 of 2114 relevant lines covered (41.11%)

0.78 hits per line

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

86.99
/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
    ) {
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->resolveImportSource($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->resolveImportSource($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
     * @param string $mediaRootPath
95
     * @param string $attachmentExportPath
96
     *
97
     * @return int|null
98
     */
99
    public function estimateAttachmentSizeBytes(
100
        string $mediaRootPath,
101
        string $attachmentExportPath
102
    ): ?int {
NEW
103
        $resolved = $this->resolveImportSource($mediaRootPath, $attachmentExportPath);
×
104

NEW
105
        return $resolved['size_bytes'] ?? null;
×
106
    }
107

108
    /**
109
     * @param string $mediaRootPath
110
     * @param string $attachmentExportPath
111
     *
112
     * @return array{
113
     *     source: string,
114
     *     basename: string,
115
     *     size_bytes: int
116
     * }|null
117
     */
118
    public function resolveImportSource(string $mediaRootPath, string $attachmentExportPath): ?array
119
    {
NEW
120
        return $this->resolveSource($mediaRootPath, $attachmentExportPath);
×
121
    }
122

123
    /**
124
     * Разрешает исходный файл вложения в распакованном экспорте.
125
     * Сначала пытается найти файл по точному export_path,
126
     * затем использует fallback по basename для legacy-экспортов.
127
     *
128
     * @param string $mediaRootPath        Абсолютный путь до корня распакованного экспорта.
129
     * @param string $attachmentExportPath Путь к вложению из export-данных сообщения.
130
     *
131
     * @return array{
132
     *     source: string,
133
     *     basename: string,
134
     *     size_bytes: int
135
     * }|null
136
     */
137
    private function resolveSource(string $mediaRootPath, string $attachmentExportPath): ?array
138
    {
139
        $root = rtrim($mediaRootPath, DIRECTORY_SEPARATOR);
4✔
140

141
        $exactPath = $this->tryResolveByExportPath($root, $attachmentExportPath);
4✔
142
        if ($exactPath !== null) {
4✔
143
            return [
1✔
144
                'source'     => $exactPath,
1✔
145
                'basename'   => FilenameSanitizer::sanitize(basename(str_replace('\\', '/', $attachmentExportPath))),
1✔
146
                'size_bytes' => max(0, (int)(@filesize($exactPath) ?: 0)),
1✔
147
            ];
1✔
148
        }
149

150
        foreach ($this->extractCandidateBasenames($attachmentExportPath) as $candidate) {
3✔
151
            $sanitizedCandidate = FilenameSanitizer::sanitize($candidate);
3✔
152
            if ($sanitizedCandidate === 'file') {
3✔
153
                continue;
×
154
            }
155
            $found = $this->findUniqueFileByBasename($root, $sanitizedCandidate);
3✔
156
            if ($found !== null) {
3✔
157
                return [
1✔
158
                    'source'     => $found,
1✔
159
                    'basename'   => $sanitizedCandidate,
1✔
160
                    'size_bytes' => max(0, (int)(@filesize($found) ?: 0)),
1✔
161
                ];
1✔
162
            }
163
        }
164

165
        Log::debug('Import media file not found', [
2✔
166
            'export_path' => $attachmentExportPath,
2✔
167
            'basename'    => basename(str_replace('\\', '/', $attachmentExportPath)),
2✔
168
            'root'        => $mediaRootPath,
2✔
169
        ]);
2✔
170

171
        return null;
2✔
172
    }
173

174
    /**
175
     * Пытается разрешить вложение по точному пути из export-данных.
176
     * Выполняет базовые проверки безопасности пути и гарантирует,
177
     * что итоговый файл расположен внутри корня распакованного экспорта.
178
     *
179
     * @param string $root                 Абсолютный путь до корня распакованного экспорта.
180
     * @param string $attachmentExportPath Путь к вложению из export-данных сообщения.
181
     *
182
     * @return string|null Абсолютный путь к найденному файлу или null.
183
     */
184
    private function tryResolveByExportPath(string $root, string $attachmentExportPath): ?string
185
    {
186
        $relativePath = trim(str_replace('\\', '/', $attachmentExportPath));
4✔
187
        if ($relativePath === '' || str_contains($relativePath, "\0")) {
4✔
188
            return null;
1✔
189
        }
190

191
        $relativePath = ltrim($relativePath, '/');
4✔
192
        $parts        = explode('/', $relativePath);
4✔
193
        if (in_array('..', $parts, true)) {
4✔
194
            return null;
1✔
195
        }
196

197
        $candidate = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
4✔
198
        if (!is_file($candidate)) {
4✔
199
            return null;
2✔
200
        }
201

202
        $candidateReal = realpath($candidate);
2✔
203
        $rootReal      = realpath($root);
2✔
204
        if ($candidateReal === false || $rootReal === false) {
2✔
205
            return null;
×
206
        }
207

208
        if ($candidateReal === $rootReal) {
2✔
209
            return $candidateReal;
×
210
        }
211

212
        $prefix = rtrim($rootReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
2✔
213

214
        return str_starts_with($candidateReal . DIRECTORY_SEPARATOR, $prefix)
2✔
215
            ? $candidateReal
2✔
216
            : null;
2✔
217
    }
218

219
    /**
220
     * Потоково записывает исходный файл в Storage по целевому относительному пути.
221
     * Используется вместо загрузки всего файла в память.
222
     *
223
     * @param string $sourcePath     Абсолютный путь к исходному файлу на диске.
224
     * @param string $storedRelative Относительный путь назначения в Storage.
225
     *
226
     * @return bool true, если файл успешно записан.
227
     */
228
    private function storeStream(string $sourcePath, string $storedRelative): bool
229
    {
230
        $stream = @fopen($sourcePath, 'rb');
1✔
231
        if (!is_resource($stream)) {
1✔
232
            return false;
×
233
        }
234

235
        try {
236
            return $this->mediaStorage->putStream($storedRelative, $stream);
1✔
237
        } finally {
238
            fclose($stream);
1✔
239
        }
240
    }
241

242
    /**
243
     * Формирует список кандидатов basename для legacy export_path без слешей.
244
     * Например, из "001_ABC_photo.jpg" дополнительно извлекается "ABC_photo.jpg" и "photo.jpg".
245
     *
246
     * @param string $attachmentExportPath Путь к вложению из export-данных сообщения.
247
     *
248
     * @return array<int, string> Уникальный список кандидатов basename в порядке приоритета.
249
     */
250
    private function extractCandidateBasenames(string $attachmentExportPath): array
251
    {
252
        $normalized = str_replace('\\', '/', $attachmentExportPath);
4✔
253
        $basename   = basename($normalized);
4✔
254
        $candidates = [$basename];
4✔
255

256
        if (!str_contains($normalized, '/')) {
4✔
257
            $parts = explode('_', $normalized);
3✔
258
            for ($i = 1; $i < count($parts); $i++) {
3✔
259
                $suffix = implode('_', array_slice($parts, $i));
3✔
260
                if ($suffix !== '' && str_contains($suffix, '.')) {
3✔
261
                    $candidates[] = $suffix;
3✔
262
                }
263
            }
264
        }
265

266
        return array_values(array_unique($candidates));
4✔
267
    }
268

269
    /**
270
     * Ищет файл по basename рекурсивно в директории и возвращает путь,
271
     * только если найдено ровно одно совпадение.
272
     * При нескольких совпадениях возвращает null и пишет warning в лог.
273
     *
274
     * @param string $dir      Абсолютный путь до корневой директории поиска.
275
     * @param string $basename Искомое имя файла (или candidate из legacy-правила).
276
     *
277
     * @return string|null Абсолютный путь к единственному найденному файлу или null.
278
     */
279
    private function findUniqueFileByBasename(string $dir, string $basename): ?string
280
    {
281
        if (!is_dir($dir)) {
4✔
282
            return null;
1✔
283
        }
284

285
        $target = strtolower(FilenameSanitizer::sanitize($basename));
4✔
286
        if ($target === '' || $target === 'file') {
4✔
287
            return null;
1✔
288
        }
289

290
        $index   = $this->getBasenameIndex($dir);
4✔
291
        $matches = $index[$target] ?? [];
4✔
292

293
        if (count($matches) === 1) {
4✔
294
            return $matches[0];
2✔
295
        }
296
        if (count($matches) > 1) {
3✔
297
            Log::warning('Import media file match is ambiguous', [
2✔
298
                'basename' => $basename,
2✔
299
                'root'     => $dir,
2✔
300
                'count'    => count($matches),
2✔
301
            ]);
2✔
302
        }
303

304
        return null;
3✔
305
    }
306

307
    /**
308
     * Строит (и кэширует) индекс файлов по basename.
309
     *
310
     * @param string $root
311
     *
312
     * @return array<string, array<int, string>>
313
     */
314
    private function getBasenameIndex(string $root): array
315
    {
316
        if (isset($this->basenameIndexCache[$root])) {
4✔
317
            return $this->basenameIndexCache[$root];
2✔
318
        }
319

320
        if (!is_dir($root)) {
4✔
321
            $this->basenameIndexCache[$root] = [];
×
322

323
            return $this->basenameIndexCache[$root];
×
324
        }
325

326
        $index = [];
4✔
327
        $stack = [$root];
4✔
328

329
        while ($stack !== []) {
4✔
330
            $scanDir = array_pop($stack);
4✔
331
            if (!is_string($scanDir)) {
4✔
332
                continue;
×
333
            }
334

335
            $items = @scandir($scanDir);
4✔
336
            if ($items === false) {
4✔
337
                continue;
×
338
            }
339

340
            foreach ($items as $item) {
4✔
341
                if ($item === '.' || $item === '..') {
4✔
342
                    continue;
4✔
343
                }
344

345
                $fullPath = $scanDir . DIRECTORY_SEPARATOR . $item;
4✔
346
                if (is_dir($fullPath)) {
4✔
347
                    $stack[] = $fullPath;
4✔
348
                    continue;
4✔
349
                }
350

351
                if (!is_file($fullPath)) {
4✔
352
                    continue;
×
353
                }
354

355
                $sanitized = strtolower(FilenameSanitizer::sanitize($item));
4✔
356
                if ($sanitized === '' || $sanitized === 'file') {
4✔
357
                    continue;
×
358
                }
359

360
                $index[$sanitized][] = $fullPath;
4✔
361
            }
362
        }
363

364
        $this->basenameIndexCache[$root] = $index;
4✔
365

366
        return $this->basenameIndexCache[$root];
4✔
367
    }
368
}
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