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

LeTraceurSnorkLibrary / MessSaga / 23844646769

01 Apr 2026 10:42AM UTC coverage: 31.674% (+31.1%) from 0.549%
23844646769

push

github

web-flow
feat: media files import

431 of 801 new or added lines in 30 files covered. (53.81%)

4 existing lines in 4 files now uncovered.

439 of 1386 relevant lines covered (31.67%)

0.74 hits per line

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

89.74
/app/Services/Media/MediaFileStorageService.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace App\Services\Media;
6

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

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

45
        $storedRelative = sprintf('conversations/%d/media/%s', $conversationId, $resolved['basename']);
1✔
46
        if (!$this->storeStream($resolved['source'], $storedRelative)) {
1✔
NEW
47
            return null;
×
48
        }
49

50
        return $storedRelative;
1✔
51
    }
52

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

78
        $storedRelative = sprintf('conversations/%d/media/%d/%s', $conversationId, $messageId, $resolved['basename']);
1✔
79
        if (!$this->storeStream($resolved['source'], $storedRelative)) {
1✔
NEW
80
            return null;
×
81
        }
82

83
        return $storedRelative;
1✔
84
    }
85

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

103
        $exactPath = $this->tryResolveByExportPath($root, $attachmentExportPath);
4✔
104
        if ($exactPath !== null) {
4✔
105
            return [
1✔
106
                'source'   => $exactPath,
1✔
107
                'basename' => FilenameSanitizer::sanitize(basename(str_replace('\\', '/', $attachmentExportPath))),
1✔
108
            ];
1✔
109
        }
110

111
        foreach ($this->extractCandidateBasenames($attachmentExportPath) as $candidate) {
3✔
112
            $sanitizedCandidate = FilenameSanitizer::sanitize($candidate);
3✔
113
            if ($sanitizedCandidate === 'file') {
3✔
NEW
114
                continue;
×
115
            }
116
            $found = $this->findUniqueFileByBasename($root, $sanitizedCandidate);
3✔
117
            if ($found !== null) {
3✔
118
                return [
1✔
119
                    'source'   => $found,
1✔
120
                    'basename' => $sanitizedCandidate,
1✔
121
                ];
1✔
122
            }
123
        }
124

125
        Log::debug('Import media file not found', [
2✔
126
            'export_path' => $attachmentExportPath,
2✔
127
            'basename'    => basename(str_replace('\\', '/', $attachmentExportPath)),
2✔
128
            'root'        => $mediaRootPath,
2✔
129
        ]);
2✔
130

131
        return null;
2✔
132
    }
133

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

151
        $relativePath = ltrim($relativePath, '/');
4✔
152
        $parts        = explode('/', $relativePath);
4✔
153
        if (in_array('..', $parts, true)) {
4✔
154
            return null;
1✔
155
        }
156

157
        $candidate = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
4✔
158
        if (!is_file($candidate)) {
4✔
159
            return null;
2✔
160
        }
161

162
        $candidateReal = realpath($candidate);
2✔
163
        $rootReal      = realpath($root);
2✔
164
        if ($candidateReal === false || $rootReal === false) {
2✔
NEW
165
            return null;
×
166
        }
167

168
        if ($candidateReal === $rootReal) {
2✔
NEW
169
            return $candidateReal;
×
170
        }
171

172
        $prefix = rtrim($rootReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
2✔
173

174
        return str_starts_with($candidateReal . DIRECTORY_SEPARATOR, $prefix)
2✔
175
            ? $candidateReal
2✔
176
            : null;
2✔
177
    }
178

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

195
        try {
196
            return Storage::put($storedRelative, $stream) === true;
1✔
197
        } finally {
198
            fclose($stream);
1✔
199
        }
200
    }
201

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

216
        if (!str_contains($normalized, '/')) {
4✔
217
            $parts = explode('_', $normalized);
3✔
218
            for ($i = 1; $i < count($parts); $i++) {
3✔
219
                $suffix = implode('_', array_slice($parts, $i));
3✔
220
                if ($suffix !== '' && str_contains($suffix, '.')) {
3✔
221
                    $candidates[] = $suffix;
3✔
222
                }
223
            }
224
        }
225

226
        return array_values(array_unique($candidates));
4✔
227
    }
228

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

245
        $target = strtolower(FilenameSanitizer::sanitize($basename));
4✔
246
        if ($target === '' || $target === 'file') {
4✔
247
            return null;
1✔
248
        }
249

250
        $index   = $this->getBasenameIndex($dir);
4✔
251
        $matches = $index[$target] ?? [];
4✔
252

253
        if (count($matches) === 1) {
4✔
254
            return $matches[0];
2✔
255
        }
256
        if (count($matches) > 1) {
3✔
257
            Log::warning('Import media file match is ambiguous', [
2✔
258
                'basename' => $basename,
2✔
259
                'root'     => $dir,
2✔
260
                'count'    => count($matches),
2✔
261
            ]);
2✔
262
        }
263

264
        return null;
3✔
265
    }
266

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

280
        if (!is_dir($root)) {
4✔
NEW
281
            $this->basenameIndexCache[$root] = [];
×
282

NEW
283
            return $this->basenameIndexCache[$root];
×
284
        }
285

286
        $index = [];
4✔
287
        $stack = [$root];
4✔
288

289
        while ($stack !== []) {
4✔
290
            $scanDir = array_pop($stack);
4✔
291
            if (!is_string($scanDir)) {
4✔
NEW
292
                continue;
×
293
            }
294

295
            $items = @scandir($scanDir);
4✔
296
            if ($items === false) {
4✔
NEW
297
                continue;
×
298
            }
299

300
            foreach ($items as $item) {
4✔
301
                if ($item === '.' || $item === '..') {
4✔
302
                    continue;
4✔
303
                }
304

305
                $fullPath = $scanDir . DIRECTORY_SEPARATOR . $item;
4✔
306
                if (is_dir($fullPath)) {
4✔
307
                    $stack[] = $fullPath;
4✔
308
                    continue;
4✔
309
                }
310

311
                if (!is_file($fullPath)) {
4✔
NEW
312
                    continue;
×
313
                }
314

315
                $sanitized = strtolower(FilenameSanitizer::sanitize($item));
4✔
316
                if ($sanitized === '' || $sanitized === 'file') {
4✔
NEW
317
                    continue;
×
318
                }
319

320
                $index[$sanitized][] = $fullPath;
4✔
321
            }
322
        }
323

324
        $this->basenameIndexCache[$root] = $index;
4✔
325

326
        return $this->basenameIndexCache[$root];
4✔
327
    }
328
}
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