Passed
Pull Request — master (#6935)
by
unknown
08:44
created

FileExport::deriveRelativeDirAndName()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 23
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 13
c 0
b 0
f 0
nc 8
nop 1
dl 0
loc 23
rs 9.8333
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder;
8
9
use Chamilo\CoreBundle\Framework\Container;
10
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ActivityExport;
11
use Chamilo\CourseBundle\Entity\CDocument;
12
use DocumentManager;
13
use Exception;
14
15
use const PATHINFO_EXTENSION;
16
use const PHP_EOL;
17
18
/**
19
 * Class FileExport.
20
 * Handles the export of files and metadata from Moodle courses.
21
 */
22
class FileExport
23
{
24
    /**
25
     * @var object
26
     */
27
    private $course;
28
29
    /**
30
     * Module context id for the mod_folder activity inside the backup.
31
     * Default kept for safety; MoodleExport must override via setModuleContextId().
32
     */
33
    private int $moduleContextId = 1000000;
34
35
    /**
36
     * Keep legacy folder-children traversal but disabled by default to avoid duplicates.
37
     * You can re-enable via setUseFolderTraversal(true) without losing this code path.
38
     */
39
    private bool $useFolderTraversal = false;
40
41
    /**
42
     * Constructor to initialize course data.
43
     *
44
     * @param object $course course object containing resources and path data
45
     */
46
    public function __construct(object $course)
47
    {
48
        $this->course = $course;
49
    }
50
51
    /**
52
     * Allow caller (MoodleExport) to set the real context id of the created mod_folder activity.
53
     */
54
    public function setModuleContextId(int $ctx): void
55
    {
56
        // INFO: caller should pass the module's context placeholder id from activities/folder_* (e.g. 1000000)
57
        $this->moduleContextId = $ctx;
58
        @error_log('[FileExport] Module context id set to '.$this->moduleContextId);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

58
        /** @scrutinizer ignore-unhandled */ @error_log('[FileExport] Module context id set to '.$this->moduleContextId);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
59
    }
60
61
    /**
62
     * Keep legacy folder-children traversal available (OFF by default).
63
     * Turning it on may create duplicates, but dedupe will catch them.
64
     */
65
    public function setUseFolderTraversal(bool $on): void
66
    {
67
        $this->useFolderTraversal = $on;
68
        @error_log('[FileExport] Use folder traversal = '.($on ? 'true' : 'false'));
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

68
        /** @scrutinizer ignore-unhandled */ @error_log('[FileExport] Use folder traversal = '.($on ? 'true' : 'false'));

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
69
    }
70
71
    /**
72
     * Export files and metadata from files.xml to the specified directory.
73
     */
74
    public function exportFiles(array $filesData, string $exportDir): void
75
    {
76
        @error_log('[FileExport::exportFiles] Start. exportDir='.$exportDir.' inputCount='.(int)count($filesData['files'] ?? []));
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

76
        /** @scrutinizer ignore-unhandled */ @error_log('[FileExport::exportFiles] Start. exportDir='.$exportDir.' inputCount='.(int)count($filesData['files'] ?? []));

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
77
78
        $filesDir = $exportDir.'/files';
79
        if (!is_dir($filesDir)) {
80
            mkdir($filesDir, api_get_permissions_for_new_directories(), true);
81
            @error_log('[FileExport::exportFiles] Created dir '.$filesDir);
82
        }
83
        $this->createPlaceholderFile($filesDir);
84
85
        $unique = ['files' => []];
86
        $seenKeys = [];
87
88
        $dedupSkipped = 0;
89
        $badPaths = 0;
90
91
        foreach (($filesData['files'] ?? []) as $idx => $file) {
92
            // Normalize every row to what Moodle restore expects for mod_folder
93
            $file = $this->normalizeRow($file);
94
95
            $ch   = (string) ($file['contenthash'] ?? '');
96
            $path = $this->ensureTrailingSlash((string) ($file['filepath'] ?? '/'));
97
            $name = (string) ($file['filename'] ?? '');
98
99
            if ('' === $ch || '' === $name) {
100
                @error_log('[FileExport::exportFiles] WARNING: Skipping entry idx='.$idx.' (missing contenthash or filename).');
101
                continue;
102
            }
103
104
            // Dedupe across sources (do NOT include component/filearea/contextid to actually remove duplicates)
105
            $dedupeKey = implode('|', [$ch, $path, $name]);
106
            if (isset($seenKeys[$dedupeKey])) {
107
                $dedupSkipped++;
108
                continue;
109
            }
110
            $seenKeys[$dedupeKey] = true;
111
112
            // register for inforef resolution
113
            FileIndex::register($file);
114
115
            if (strpos($path, '/Documents/') === 0) {
116
                $badPaths++;
117
                @error_log('[FileExport::exportFiles] WARNING: filepath starts with /Documents/ (Moodle folder expects /). filepath='.$path.' filename='.$name);
118
            }
119
120
            $file['filepath'] = $path;
121
            $unique['files'][] = $file;
122
        }
123
124
        @error_log('[FileExport::exportFiles] After dedupe: '.count($unique['files']).' file(s). dedupSkipped='.$dedupSkipped.' badPathsDetected='.$badPaths);
125
126
        $copied = 0;
127
        foreach ($unique['files'] as $f) {
128
            $ch = (string) $f['contenthash'];
129
            $subdir = FileIndex::resolveSubdirByContenthash($ch);
130
            $this->copyFileToExportDir($f, $filesDir, $subdir);
131
            $copied++;
132
        }
133
        @error_log('[FileExport::exportFiles] Copied payloads: '.$copied);
134
135
        $this->createFilesXml($unique, $exportDir);
136
        @error_log('[FileExport::exportFiles] Done.');
137
    }
138
139
    /**
140
     * Get file data from course resources. This is for testing purposes.
141
     *
142
     * @return array<string,mixed>
143
     */
144
    public function getFilesData(): array
145
    {
146
        $adminData = MoodleExport::getAdminUserData();
147
        $adminId = $adminData['id'] ?? 0;
148
149
        $filesData = ['files' => []];
150
151
        // Defensive read: documents may be missing
152
        $docResources = $this->course->resources[RESOURCE_DOCUMENT] ?? [];
153
        if (!\is_array($docResources)) {
154
            $docResources = [];
155
        }
156
157
        foreach ($docResources as $document) {
158
            $filesData = $this->processDocument($filesData, $document);
159
        }
160
161
        // Defensive read: works may be missing (avoids "Undefined array key 'work'")
162
        $workResources = $this->course->resources[RESOURCE_WORK] ?? [];
163
        if (!\is_array($workResources)) {
164
            $workResources = [];
165
        }
166
167
        foreach ($workResources as $work) {
168
            // getAllDocumentToWork might not exist in some installs; guard it
169
            $workFiles = \function_exists('getAllDocumentToWork')
170
                ? (getAllDocumentToWork($work->params['id'] ?? 0, $this->course->info['real_id'] ?? 0) ?: [])
171
                : [];
172
173
            if (!\is_array($workFiles) || empty($workFiles)) {
174
                continue;
175
            }
176
177
            foreach ($workFiles as $file) {
178
                // Safely fetch doc data
179
                $docId = (int) ($file['document_id'] ?? 0);
180
                if ($docId <= 0) {
181
                    continue;
182
                }
183
184
                $docData = DocumentManager::get_document_data_by_id(
0 ignored issues
show
Deprecated Code introduced by
The function DocumentManager::get_document_data_by_id() has been deprecated: use $repo->find() ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

184
                $docData = /** @scrutinizer ignore-deprecated */ DocumentManager::get_document_data_by_id(

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
185
                    $docId,
186
                    (string) ($this->course->info['code'] ?? '')
187
                );
188
189
                if (!\is_array($docData) || empty($docData['path'])) {
190
                    continue;
191
                }
192
193
                $row = [
194
                    'id'           => $docId,
195
                    'contenthash'  => hash('sha1', basename($docData['path'])),
196
                    'contextid'    => (int) ($this->course->info['real_id'] ?? 0), // will be normalized to moduleContextId
197
                    'component'    => 'mod_assign', // will be normalized to mod_folder
198
                    'filearea'     => 'introattachment', // will be normalized to content
199
                    'itemid'       => (int) ($work->params['id'] ?? 0), // will be normalized to 0
200
                    'filepath'     => '/Documents/', // will be normalized
201
                    'documentpath' => 'document/'.$docData['path'],
202
                    'filename'     => basename($docData['path']),
203
                    'userid'       => $adminId,
204
                    'filesize'     => (int) ($docData['size'] ?? 0),
205
                    'mimetype'     => $this->getMimeType($docData['path']),
206
                    'status'       => 0,
207
                    'timecreated'  => time() - 3600,
208
                    'timemodified' => time(),
209
                    'source'       => (string) ($docData['title'] ?? ''),
210
                    'author'       => 'Unknown',
211
                    'license'      => 'allrightsreserved',
212
                ];
213
214
                // Normalize to folder activity before pushing
215
                $filesData['files'][] = $this->normalizeRow($row);
216
            }
217
        }
218
219
        return $filesData;
220
    }
221
222
    /**
223
     * Create a placeholder index.html file to prevent an empty directory.
224
     */
225
    private function createPlaceholderFile(string $filesDir): void
226
    {
227
        $placeholderFile = $filesDir.'/index.html';
228
        file_put_contents($placeholderFile, '<!-- Placeholder file to ensure the directory is not empty -->');
229
    }
230
231
    /**
232
     * Copy a file to the export directory using its contenthash.
233
     *
234
     * @param array<string,mixed> $file
235
     */
236
    private function copyFileToExportDir(array $file, string $filesDir, ?string $precomputedSubdir = null): void
237
    {
238
        $fp = (string)($file['filepath'] ?? '.');
239
        if ($fp === '.') {
240
            @error_log('[FileExport::copyFileToExportDir] Skipping file with filepath dot. id='.(int)($file['id'] ?? 0));
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

240
            /** @scrutinizer ignore-unhandled */ @error_log('[FileExport::copyFileToExportDir] Skipping file with filepath dot. id='.(int)($file['id'] ?? 0));

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
241
            return;
242
        }
243
244
        $contenthash = (string) $file['contenthash'];
245
        $subDir = $precomputedSubdir ?: substr($contenthash, 0, 2);
246
        $exportSubDir = $filesDir.'/'.$subDir;
247
248
        if (!is_dir($exportSubDir)) {
249
            mkdir($exportSubDir, api_get_permissions_for_new_directories(), true);
250
            @error_log('[FileExport::copyFileToExportDir] Created subdir '.$exportSubDir);
251
        }
252
253
        $destinationFile = $exportSubDir.'/'.$contenthash;
254
255
        $filePath = $file['abs_path'] ?? null;
256
        if (!$filePath) {
257
            $filePath = $this->course->path.$file['documentpath'];
258
        }
259
260
        if (is_file($filePath)) {
261
            if (!is_file($destinationFile)) {
262
                if (@copy($filePath, $destinationFile)) {
263
                    @error_log('[FileExport::copyFileToExportDir] OK copy id='.(int)($file['id'] ?? 0).' -> '.$destinationFile);
264
                } else {
265
                    @error_log('[FileExport::copyFileToExportDir] ERROR copy failed id='.(int)($file['id'] ?? 0).' src='.$filePath.' dst='.$destinationFile);
266
                }
267
            } else {
268
                @error_log('[FileExport::copyFileToExportDir] Already exists dst='.$destinationFile);
269
            }
270
        } else {
271
            @error_log('[FileExport::copyFileToExportDir] ERROR source not found: '.$filePath.' (id='.(int)($file['id'] ?? 0).')');
272
            throw new Exception("Source file not found: {$filePath}");
273
        }
274
    }
275
276
    /**
277
     * Create the files.xml with the provided file data.
278
     *
279
     * @param array<string,mixed> $filesData
280
     */
281
    private function createFilesXml(array $filesData, string $destinationDir): void
282
    {
283
        @error_log('[FileExport::createFilesXml] Start. destinationDir='.$destinationDir);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

283
        /** @scrutinizer ignore-unhandled */ @error_log('[FileExport::createFilesXml] Start. destinationDir='.$destinationDir);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
284
285
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
286
        $xmlContent .= '<files>'.PHP_EOL;
287
288
        $total = 0;
289
        $modFolder = 0;
290
        $badFilepath = 0;
291
        $wrongItemId = 0;
292
293
        foreach (($filesData['files'] ?? []) as $file) {
294
            // Safety: ensure already-normalized rows
295
            $file = $this->normalizeRow($file);
296
297
            $xmlContent .= $this->createFileXmlEntry($file);
298
            $total++;
299
300
            $comp = (string)($file['component'] ?? '');
301
            $area = (string)($file['filearea'] ?? '');
302
            $fp   = (string)($file['filepath'] ?? '');
303
            $it   = (int)($file['itemid'] ?? 0);
304
305
            if ($comp === 'mod_folder' && $area === 'content') {
306
                $modFolder++;
307
                if ($fp === '' || $fp[0] !== '/' || substr($fp, -1) !== '/') {
308
                    $badFilepath++;
309
                    @error_log('[FileExport::createFilesXml] WARNING bad filepath (must start/end with /): id='.(int)($file['id'] ?? 0).' filepath='.$fp);
310
                }
311
                if ($it !== 0) {
312
                    $wrongItemId++;
313
                    @error_log('[FileExport::createFilesXml] WARNING itemid must be 0 for mod_folder/content id='.(int)($file['id'] ?? 0).' itemid='.$it);
314
                }
315
            }
316
        }
317
318
        $xmlContent .= '</files>'.PHP_EOL;
319
        file_put_contents($destinationDir.'/files.xml', $xmlContent);
320
321
        @error_log('[FileExport::createFilesXml] Done. total='.$total.' mod_folder='.$modFolder.' badFilepath='.$badFilepath.' wrongItemId='.$wrongItemId);
322
    }
323
324
    /**
325
     * Create an XML entry for a file.
326
     *
327
     * @param array<string,mixed> $file
328
     */
329
    private function createFileXmlEntry(array $file): string
330
    {
331
        // itemid is forced to 0 in V1 files.xml for consistency with restore
332
        return '  <file id="'.(int) $file['id'].'">'.PHP_EOL.
333
            '    <contenthash>'.htmlspecialchars((string) $file['contenthash']).'</contenthash>'.PHP_EOL.
334
            '    <contextid>'.(int) $file['contextid'].'</contextid>'.PHP_EOL.
335
            '    <component>'.htmlspecialchars((string) $file['component']).'</component>'.PHP_EOL.
336
            '    <filearea>'.htmlspecialchars((string) $file['filearea']).'</filearea>'.PHP_EOL.
337
            '    <itemid>0</itemid>'.PHP_EOL.
338
            '    <filepath>'.htmlspecialchars((string) $file['filepath']).'</filepath>'.PHP_EOL.
339
            '    <filename>'.htmlspecialchars((string) $file['filename']).'</filename>'.PHP_EOL.
340
            '    <userid>'.(int) $file['userid'].'</userid>'.PHP_EOL.
341
            '    <filesize>'.(int) $file['filesize'].'</filesize>'.PHP_EOL.
342
            '    <mimetype>'.htmlspecialchars((string) $file['mimetype']).'</mimetype>'.PHP_EOL.
343
            '    <status>'.(int) $file['status'].'</status>'.PHP_EOL.
344
            '    <timecreated>'.(int) $file['timecreated'].'</timecreated>'.PHP_EOL.
345
            '    <timemodified>'.(int) $file['timemodified'].'</timemodified>'.PHP_EOL.
346
            '    <source>'.htmlspecialchars((string) $file['source']).'</source>'.PHP_EOL.
347
            '    <author>'.htmlspecialchars((string) $file['author']).'</author>'.PHP_EOL.
348
            '    <license>'.htmlspecialchars((string) $file['license']).'</license>'.PHP_EOL.
349
            '    <sortorder>0</sortorder>'.PHP_EOL.
350
            '    <repositorytype>$@NULL@$</repositorytype>'.PHP_EOL.
351
            '    <repositoryid>$@NULL@$</repositoryid>'.PHP_EOL.
352
            '    <reference>$@NULL@$</reference>'.PHP_EOL.
353
            '  </file>'.PHP_EOL;
354
    }
355
356
    /**
357
     * Process a document or folder and add its data to the files array.
358
     *
359
     * @param array<string,mixed> $filesData
360
     */
361
    private function processDocument(array $filesData, object $document): array
362
    {
363
        // Skip files already embedded/handled by PageExport
364
        if (
365
            ($document->file_type ?? null) === 'file'
366
            && isset($this->course->used_page_doc_ids)
367
            && \in_array($document->source_id, (array) $this->course->used_page_doc_ids, true)
368
        ) {
369
            @error_log('[FileExport::processDocument] Skipping file id='.$document->source_id.' (used by PageExport)');
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

369
            /** @scrutinizer ignore-unhandled */ @error_log('[FileExport::processDocument] Skipping file id='.$document->source_id.' (used by PageExport)');

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
370
            return $filesData;
371
        }
372
373
        // Skip top-level HTML documents that are exported as Page
374
        if (
375
            ($document->file_type ?? null) === 'file'
376
            && 'html' === strtolower((string)pathinfo($document->path, PATHINFO_EXTENSION))
377
            && 1 === substr_count($document->path, '/')
378
        ) {
379
            @error_log('[FileExport::processDocument] Skipping top-level HTML (will be Page) id='.$document->source_id);
380
            return $filesData;
381
        }
382
383
        // Simple FILE -> becomes part of Folder activity
384
        if (($document->file_type ?? null) === 'file') {
385
            $extension = strtolower((string) pathinfo($document->path, PATHINFO_EXTENSION));
386
            if (!\in_array($extension, ['html', 'htm'], true)) {
387
                $fileData = $this->getFileData($document);
388
389
                // Derive hierarchical folder path inside moodle folder content
390
                [$filepath, /*$fn*/, $rest] = $this->deriveRelativeDirAndName((string)$document->path);
391
                $fileData['filepath']  = $filepath;          // "/folder001/" or "/folder001/subfolder 001/" or "/"
392
393
                // Normalize to mod_folder + moduleContextId
394
                $fileData = $this->normalizeRow($fileData);
395
396
                @error_log('[FileExport::processDocument] FILE id='.$fileData['id'].' rest='.$rest.' -> fp='.$fileData['filepath'].' name='.$fileData['filename']);
397
398
                $filesData['files'][] = $fileData;
399
            }
400
        } elseif (($document->file_type ?? null) === 'folder') {
401
            if (!$this->useFolderTraversal) {
402
                @error_log('[FileExport::processDocument] INFO: folder traversal disabled, skipping folder iid='.(int)$document->source_id.' (kept code for compatibility)');
403
                return $filesData;
404
            }
405
406
            $docRepo = Container::getDocumentRepository();
407
            $folderFiles = $docRepo->listFilesByParentIid((int) $document->source_id);
408
409
            @error_log('[FileExport::processDocument] FOLDER iid='.(int)$document->source_id.' contains '.count($folderFiles).' file(s)');
410
411
            foreach ($folderFiles as $file) {
412
                [$filepath, $fn, $rest] = $this->deriveRelativeDirAndName((string)($file['path'] ?? ''));
413
414
                $row = $this->getFolderFileData(
415
                    $file,
416
                    (int)$document->source_id,
417
                    $filepath
418
                );
419
420
                // Normalize to mod_folder + moduleContextId
421
                $row = $this->normalizeRow($row);
422
423
                @error_log('[FileExport::processDocument] CHILD id='.$row['id'].' rest='.$rest.' -> fp='.$row['filepath'].' name='.$row['filename']);
424
425
                $filesData['files'][] = $row;
426
            }
427
        }
428
429
        return $filesData;
430
    }
431
432
    /**
433
     * Normalize one row to Moodle mod_folder/content requirements.
434
     * - component=mod_folder
435
     * - filearea=content
436
     * - itemid=0
437
     * - contextid=$this->moduleContextId
438
     * - filepath normalized, never "/Documents/"
439
     */
440
    private function normalizeRow(array $row): array
441
    {
442
        $row['component'] = 'mod_folder';
443
        $row['filearea']  = 'content';
444
        $row['itemid']    = 0;
445
        $row['contextid'] = $this->moduleContextId;
446
447
        $fp = (string)($row['filepath'] ?? '/');
448
        if ($fp === '' || $fp === '.' || $fp === '/') {
449
            $fp = '/';
450
        } else {
451
            // convert legacy /Documents/... to /
452
            if (strpos($fp, '/Documents/') === 0) {
453
                $fp = '/';
454
            }
455
        }
456
        $row['filepath'] = $this->ensureTrailingSlash($fp);
457
458
        // Safety: filename must never be empty
459
        $row['filename'] = (string)($row['filename'] ?? '');
460
        if ($row['filename'] === '') {
461
            $row['filename'] = 'unnamed';
462
        }
463
464
        return $row;
465
    }
466
467
    private function deriveRelativeDirAndName(string $absolutePath): array
468
    {
469
        $code = (string)($this->course->code ?? '');
470
        $raw  = str_replace('\\', '/', $absolutePath);
471
        $raw  = ltrim($raw, '/');
472
473
        // Look for the course code segment and take the remainder
474
        if (preg_match('#(?:^|/)'.preg_quote($code, '#').'/+(.*)$#', $raw, $m)) {
475
            $rest = $m[1]; // e.g. "folder001/subfolder 001/settings-changed.odt"
476
        } else {
477
            // Fallback: trim common prefixes document/document/localhost/...
478
            $rest = preg_replace('#^document/(?:document/)?(?:[^/]+/)?#', '', $raw);
479
        }
480
481
        $rest     = trim((string)$rest, '/');
482
        $filename = basename($rest);
483
        $dir      = trim((string)dirname($rest), '.');
484
485
        $filepath = ($dir === '' || $dir === '/') ? '/' : '/'.$dir.'/';
486
487
        @error_log('[FileExport::deriveRelativeDirAndName] code='.$code.' raw='.$absolutePath.' rest='.$rest.' dir='.$dir.' -> filepath='.$filepath.' filename='.$filename);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

487
        /** @scrutinizer ignore-unhandled */ @error_log('[FileExport::deriveRelativeDirAndName] code='.$code.' raw='.$absolutePath.' rest='.$rest.' dir='.$dir.' -> filepath='.$filepath.' filename='.$filename);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
488
489
        return [$filepath, $filename, $rest];
490
    }
491
492
    private function normalizeMoodleFilepath(string $path): string
493
    {
494
        $path = str_replace('\\', '/', trim($path));
495
        if ($path === '' || $path === '.' || $path === '/') {
496
            return '/';
497
        }
498
        $path = ltrim($path, '/');
499
        $path = (string)preg_replace('#/+#', '/', $path);
500
        $path = trim($path, '/');
501
        return '/'.$path.'/';
502
    }
503
504
    /**
505
     * Get file data for a single document.
506
     *
507
     * @return array<string,mixed>
508
     */
509
    private function getFileData(object $document): array
510
    {
511
        $adminData = MoodleExport::getAdminUserData();
512
        $adminId = $adminData['id'] ?? 0;
513
514
        $contenthash = hash('sha1', basename($document->path));
515
        $mimetype = $this->getMimeType($document->path);
516
517
        // Try to resolve absolute path for single file documents
518
        $absPath = null;
519
        if (isset($document->source_id)) {
520
            $repo = Container::getDocumentRepository();
521
            $doc = $repo->findOneBy(['iid' => (int) $document->source_id]);
522
            if ($doc instanceof CDocument) {
523
                $absPath = $repo->getAbsolutePathForDocument($doc);
524
            }
525
        }
526
527
        return [
528
            'id' => (int) $document->source_id,
529
            'contenthash' => $contenthash,
530
            'contextid' => (int) $document->source_id, // will be normalized to moduleContextId
531
            'component' => 'mod_resource',             // will be normalized to mod_folder
532
            'filearea' => 'content',                   // will be normalized (kept for compatibility)
533
            'itemid' => (int) $document->source_id,    // will be normalized to 0
534
            'filepath' => '/',                         // will be replaced by deriveRelativeDirAndName()
535
            'documentpath' => (string) $document->path,
536
            'filename' => basename($document->path),
537
            'userid' => $adminId,
538
            'filesize' => (int) $document->size,
539
            'mimetype' => $mimetype,
540
            'status' => 0,
541
            'timecreated' => time() - 3600,
542
            'timemodified' => time(),
543
            'source' => (string) $document->title,
544
            'author' => 'Unknown',
545
            'license' => 'allrightsreserved',
546
            // New: absolute path for reliable copy
547
            'abs_path' => $absPath,
548
        ];
549
    }
550
551
    /**
552
     * Get file data for files inside a folder (legacy flow preserved).
553
     *
554
     * @param array<string,mixed> $file
555
     *
556
     * @return array<string,mixed>
557
     */
558
    private function getFolderFileData(array $file, int $sourceId, string $parentPath = '/'): array
559
    {
560
        $adminData = MoodleExport::getAdminUserData();
561
        $adminId = $adminData['id'] ?? 0;
562
563
        $contenthash = hash('sha1', basename((string) $file['path']));
564
        $mimetype    = $this->getMimeType((string) $file['path']);
565
        $filename    = basename((string) $file['path']);
566
567
        $filepath = $this->normalizeMoodleFilepath($parentPath);
568
569
        return [
570
            'id'          => (int) ($file['id'] ?? 0),
571
            'contenthash' => $contenthash,
572
            'contextid'   => $sourceId, // will be normalized to moduleContextId
573
            'component'   => 'mod_folder',
574
            'filearea'    => 'content',
575
            'itemid'      => (int) ($file['id'] ?? 0), // will be normalized to 0
576
            'filepath'    => $filepath,
577
            'documentpath'=> 'document/'.$file['path'],
578
            'filename'    => $filename,
579
            'userid'      => $adminId,
580
            'filesize'    => (int) ($file['size'] ?? 0),
581
            'mimetype'    => $mimetype,
582
            'status'      => 0,
583
            'timecreated' => time() - 3600,
584
            'timemodified'=> time(),
585
            'source'      => (string) ($file['title'] ?? ''),
586
            'author'      => 'Unknown',
587
            'license'     => 'allrightsreserved',
588
            'abs_path'    => $file['abs_path'] ?? null,
589
        ];
590
    }
591
592
    /**
593
     * Ensure the directory path has a trailing slash.
594
     */
595
    private function ensureTrailingSlash(string $path): string
596
    {
597
        // Normalize slashes and remove '/./'
598
        $path = (string) str_replace('\\', '/', $path);
599
        $path = (string) preg_replace('#/\./#', '/', $path);
600
        $path = (string) preg_replace('#/+#', '/', $path);
601
602
        if ($path === '' || $path === '.' || $path === '/') {
603
            return '/';
604
        }
605
        return rtrim($path, '/').'/';
606
    }
607
608
    /**
609
     * Get MIME type based on the file extension.
610
     */
611
    public function getMimeType(string $filePath): string
612
    {
613
        $extension = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION));
614
        $mimeTypes = $this->getMimeTypes();
615
616
        return $mimeTypes[$extension] ?? 'application/octet-stream';
617
    }
618
619
    /**
620
     * Get an array of file extensions and their corresponding MIME types.
621
     *
622
     * @return array<string,string>
623
     */
624
    private function getMimeTypes(): array
625
    {
626
        return [
627
            'pdf' => 'application/pdf',
628
            'jpg' => 'image/jpeg',
629
            'jpeg' => 'image/jpeg',
630
            'png' => 'image/png',
631
            'gif' => 'image/gif',
632
            'html' => 'text/html',
633
            'htm' => 'text/html',
634
            'txt' => 'text/plain',
635
            'doc' => 'application/msword',
636
            'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
637
            'xls' => 'application/vnd.ms-excel',
638
            'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
639
            'ppt' => 'application/vnd.ms-powerpoint',
640
            'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
641
            'zip' => 'application/zip',
642
            'rar' => 'application/x-rar-compressed',
643
            'wav' => 'audio/wav',
644
        ];
645
    }
646
}
647