ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
MigrateMailAttachmentsToIRSS.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
22
23use Throwable;
36
42{
43 private const int PATHS_PER_STEP = 25;
44 private const int MAILS_PER_STEP = 50;
45 private const string SKIPPED_MARKER = '-';
46
48 private ?AdminInteraction $io = null;
49 private int $step_number = 0;
50
52 private array $statistics = [];
53
54 public function getLabel(): string
55 {
56 return 'Migrate Mail Attachments to IRSS';
57 }
58
60 {
61 return 10;
62 }
63
64 public function getPreconditions(Environment $environment): array
65 {
66 return array_merge(
69 );
70 }
71
72 public function prepare(Environment $environment): void
73 {
74 $this->io = $this->resolveAdminInteraction($environment);
75 $this->helper = new ilResourceStorageMigrationHelper(
77 $environment
78 );
79 }
80
81 public function step(Environment $environment): void
82 {
83 $this->io ??= $this->resolveAdminInteraction($environment);
84 $this->step_number++;
85 $this->statistics = [];
86 $started = microtime(true);
87
88 try {
91 } catch (Throwable $e) {
92 $this->error('Migration aborted', ['step' => $this->step_number], $e);
93
94 throw $e;
95 }
96
97 if ($this->statistics !== []) {
98 $this->info('Step finished', array_merge(
99 ['step' => $this->step_number, 'seconds' => round(microtime(true) - $started, 2)],
100 $this->statistics
101 ));
102 }
103 }
104
105 // -----------------------------------------------------------------------------------------------------------
106 // Directories of sent and received mails
107 // -----------------------------------------------------------------------------------------------------------
108
113 private function migrateSentAttachmentDirectories(): void
114 {
115 $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail';
116
117 foreach ($this->nextUnmigratedPaths() as $relative_path) {
118 $absolute_path = $mail_path . '/' . $relative_path;
119 if (trim($relative_path, '/. ') === '' || str_contains($relative_path, '..')) {
120 $this->skipPath($relative_path, 'Invalid directory name');
121
122 continue;
123 }
124
125 if (!is_dir($absolute_path)) {
126 $this->skipPath($relative_path, 'Directory does not exist');
127
128 continue;
129 }
130
131 $filenames = $this->listDirectoryFiles($absolute_path);
132 if ($filenames === []) {
133 $this->skipPath($relative_path, 'No file of the directory could be stored');
134
135 continue;
136 }
137
138 $owner_id = $this->resolveOwnerIdForPath($relative_path);
139 $rcid = $this->copyDirectoryToCollection($absolute_path, $filenames, $owner_id);
140 if ($rcid === null) {
141 $this->warning('Directory was not migrated because not every file could be stored', [
142 'path' => $relative_path,
143 'files' => count($filenames),
144 ]);
145
146 continue;
147 }
148
150 $this->assignRcidToPath($relative_path, $rcid);
151 $this->updateMailAttachmentFields($relative_path, $rcid);
152 $this->deleteMigratedDirectory($absolute_path);
153 $this->count('directories_migrated');
154 }
155 }
156
164 string $absolute_path,
165 array $filenames,
166 int $owner_id
168 $collection = $this->helper->getCollectionBuilder()->new($owner_id);
169 $copies = [];
170 foreach ($filenames as $file_name) {
171 $resource_id = $this->helper->movePathToStorage(
172 $absolute_path . '/' . $file_name,
173 $owner_id,
174 null,
175 static fn(string $name): string => md5($name),
176 true
177 );
178 if (!$resource_id instanceof ResourceIdentification) {
179 $this->discardResources($copies);
180
181 return null;
182 }
183 $copies[] = $resource_id;
184 $collection->add($resource_id);
185 }
186
187 if ($collection->count() !== count($filenames)
188 || !$this->helper->getCollectionBuilder()->store($collection)) {
189 $this->discardResources($copies);
190
191 return null;
192 }
193
194 return $collection->getIdentification();
195 }
196
202 private function discardResources(array $resource_ids): void
203 {
204 foreach ($resource_ids as $resource_id) {
205 $this->helper->getManager()->remove($resource_id, $this->helper->getStakeholder());
206 }
207 }
208
212 private function listDirectoryFiles(string $absolute_path): array
213 {
214 $filenames = [];
215 foreach (new \DirectoryIterator($absolute_path) as $file_info) {
216 if (!$file_info->isFile()) {
217 continue;
218 }
219 $filenames[] = $file_info->getFilename();
220 }
221 sort($filenames);
222
223 return $filenames;
224 }
225
226 private function deleteMigratedDirectory(string $absolute_path): void
227 {
228 foreach ($this->listDirectoryFiles($absolute_path) as $file_name) {
229 unlink($absolute_path . '/' . $file_name);
230 }
231 rmdir($absolute_path);
232 }
233
237 private function nextUnmigratedPaths(): array
238 {
239 $db = $this->helper->getDatabase();
240 $db->setLimit(self::PATHS_PER_STEP);
241 $res = $db->query(
242 'SELECT ma.path FROM mail_attachment ma
243 LEFT JOIN mail m ON m.mail_id = ma.mail_id
244 WHERE (ma.rcid IS NULL OR ma.rcid = "")
245 AND ma.path IS NOT NULL AND ma.path != ""
246 GROUP BY ma.path
247 ORDER BY MAX(m.send_time) DESC'
248 );
249
250 $paths = [];
251 while ($row = $db->fetchAssoc($res)) {
252 $paths[] = (string) $row['path'];
253 }
254
255 return $paths;
256 }
257
258 private function skipPath(string $relative_path, string $reason): void
259 {
260 $mail_ids = $this->mailIdsOfPath($relative_path);
261 $this->markPathAsSkipped($relative_path);
262 $this->count('directories_skipped');
263
264 $this->warning('Directory skipped: ' . $reason, [
265 'path' => $relative_path,
266 'mails' => count($mail_ids),
267 'mail_ids' => array_slice($mail_ids, 0, 20),
268 ]);
269 }
270
274 private function mailIdsOfPath(string $relative_path): array
275 {
276 $db = $this->helper->getDatabase();
277 $res = $db->queryF(
278 'SELECT mail_id FROM mail_attachment WHERE path = %s',
280 [$relative_path]
281 );
282
283 $mail_ids = [];
284 while ($row = $db->fetchAssoc($res)) {
285 $mail_ids[] = (int) $row['mail_id'];
286 }
287
288 return $mail_ids;
289 }
290
291 // -----------------------------------------------------------------------------------------------------------
292 // Drafts and scheduled mails referencing the attachment pool
293 // -----------------------------------------------------------------------------------------------------------
294
299 private function migrateSerializedMailAttachments(): void
300 {
301 $db = $this->helper->getDatabase();
302 $db->setLimit(self::MAILS_PER_STEP);
303 $res = $db->query(
304 'SELECT m.mail_id, m.user_id, m.attachments, fo.m_type FROM mail m
305 LEFT JOIN mail_attachment ma ON ma.mail_id = m.mail_id
306 LEFT JOIN mail_obj_data fo ON fo.obj_id = m.folder_id
307 WHERE ma.mail_id IS NULL
308 AND m.attachments LIKE ' . $db->quote('a:%', ilDBConstants::T_TEXT) . '
309 ORDER BY m.send_time DESC, m.mail_id DESC'
310 );
311
312 $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail';
313 while ($row = $db->fetchAssoc($res)) {
314 $this->migrateSerializedMail(
315 (int) $row['mail_id'],
316 (int) $row['user_id'],
317 (string) $row['attachments'],
318 $mail_path,
319 in_array(
320 $row['m_type'] ?? null,
321 [MailFolderType::DRAFTS->value, MailFolderType::OUTBOX->value],
322 true
323 )
324 );
325 }
326 }
327
328 private function migrateSerializedMail(
329 int $mail_id,
330 int $user_id,
331 string $raw_attachments,
332 string $mail_path,
333 bool $references_pool
334 ): void {
335 $attachments = MailAttachments::fromDb($raw_attachments);
336 if ($attachments === null || !$attachments->isLegacy()) {
337 if ($attachments === null) {
338 $this->warning('Attachments of mail could not be read and have been removed', [
339 'mail_id' => $mail_id,
340 'value' => substr($raw_attachments, 0, 200),
341 ]);
342 }
343 $this->clearMailAttachmentsColumn($mail_id);
344
345 return;
346 }
347
348 // Sent and received mails reference a directory. Without one, files of the owner's pool must not be attached.
349 if (!$references_pool) {
350 $this->warning('Mail outside drafts and outbox has no attachment directory, its attachments have been removed', [
351 'mail_id' => $mail_id,
352 'files' => $attachments->legacyFilenames(),
353 ]);
354 $this->clearMailAttachmentsColumn($mail_id);
355
356 return;
357 }
358
359 $rcid = $this->migratePoolFilenamesToCollection($attachments->legacyFilenames(), $user_id, $mail_path, $mail_id);
360 if ($rcid === null) {
361 $this->warning('None of the pool files referenced by the mail exists, its attachments have been removed', [
362 'mail_id' => $mail_id,
363 'files' => $attachments->legacyFilenames(),
364 ]);
365 $this->clearMailAttachmentsColumn($mail_id);
366
367 return;
368 }
369
370 $db = $this->helper->getDatabase();
371 $db->manipulateF(
372 'INSERT INTO mail_attachment (mail_id, path, rcid) VALUES (%s, %s, %s)',
374 [$mail_id, '', $rcid->serialize()]
375 );
376 $db->update(
377 'mail',
378 ['attachments' => [ilDBConstants::T_CLOB, $rcid->serialize()]],
379 ['mail_id' => [ilDBConstants::T_INTEGER, $mail_id]]
380 );
381 $this->count('drafts_migrated');
382 }
383
391 array $filenames,
392 int $user_id,
393 string $mail_path,
394 int $mail_id
396 $collection = $this->helper->getCollectionBuilder()->new($user_id);
397
398 foreach ($filenames as $filename) {
399 $absolute_path = $mail_path . '/' . $user_id . '_' . $filename;
400 if ($filename === '' || str_contains($filename, '/') || str_contains($filename, '\\') || !is_file($absolute_path)) {
401 $this->warning('Pool file referenced by the mail does not exist', [
402 'mail_id' => $mail_id,
403 'file' => $filename,
404 ]);
405
406 continue;
407 }
408
409 // Pool files are stored as "<user_id>_<name>" on disk, the users only know the name
410 $resource_id = $this->helper->movePathToStorage(
411 $absolute_path,
412 $user_id,
413 static fn(): string => $filename,
414 static fn(): string => md5($filename),
415 true
416 );
417
418 if ($resource_id instanceof ResourceIdentification) {
419 $this->markConfidential($resource_id);
420 $collection->add($resource_id);
421 }
422 }
423
424 if ($collection->count() === 0) {
425 return null;
426 }
427
428 if (!$this->helper->getCollectionBuilder()->store($collection)) {
429 $this->discardResources(array_values($collection->getResourceIdentifications()));
430
431 return null;
432 }
433
434 return $collection->getIdentification();
435 }
436
437 private function markConfidential(ResourceIdentification $identification): void
438 {
439 $this->helper->getManager()->setConfidential($identification, true);
440 }
441
443 {
444 foreach ($this->helper->getCollectionBuilder()->get($rcid)->getResourceIdentifications() as $identification) {
445 $this->markConfidential($identification);
446 }
447 }
448
449 private function clearMailAttachmentsColumn(int $mail_id): void
450 {
451 $this->helper->getDatabase()->update(
452 'mail',
453 [
454 'attachments' => [ilDBConstants::T_CLOB, ''],
455 ],
456 [
457 'mail_id' => [ilDBConstants::T_INTEGER, $mail_id],
458 ]
459 );
460 }
461
463 {
464 $db = $this->helper->getDatabase();
465
466 $path_count = (int) $db->fetchAssoc(
467 $db->query(
468 'SELECT COUNT(DISTINCT path) cnt FROM mail_attachment
469 WHERE (rcid IS NULL OR rcid = "")
470 AND path IS NOT NULL AND path != ""'
471 )
472 )['cnt'];
473
474 $mail_count = (int) $db->fetchAssoc(
475 $db->query(
476 'SELECT COUNT(m.mail_id) cnt FROM mail m
477 LEFT JOIN mail_attachment ma ON ma.mail_id = m.mail_id
478 WHERE ma.mail_id IS NULL
479 AND m.attachments LIKE ' . $db->quote('a:%', ilDBConstants::T_TEXT)
480 )
481 )['cnt'];
482
483 $remaining = max(
484 (int) ceil($path_count / self::PATHS_PER_STEP),
485 (int) ceil($mail_count / self::MAILS_PER_STEP)
486 );
487
488 $this->info('Remaining', [
489 'directories' => $path_count,
490 'drafts' => $mail_count,
491 'steps' => $remaining,
492 ]);
493
494 return $remaining;
495 }
496
497 private function resolveOwnerIdForPath(string $relative_path): int
498 {
499 $db = $this->helper->getDatabase();
500 $db->setLimit(1, 0);
501 $res = $db->queryF(
502 'SELECT m.sender_id FROM mail_attachment ma
503 INNER JOIN mail m ON m.mail_id = ma.mail_id
504 WHERE ma.path = %s
505 ORDER BY m.send_time ASC',
507 [$relative_path]
508 );
509
510 $row = $db->fetchAssoc($res);
511 if (is_array($row) && (int) $row['sender_id'] > 0) {
512 return (int) $row['sender_id'];
513 }
514
515 return defined('SYSTEM_USER_ID') ? (int) SYSTEM_USER_ID : 6;
516 }
517
518 private function assignRcidToPath(string $relative_path, ResourceCollectionIdentification $rcid): void
519 {
520 $this->helper->getDatabase()->manipulateF(
521 'UPDATE mail_attachment SET rcid = %s WHERE path = %s',
523 [$rcid->serialize(), $relative_path]
524 );
525 }
526
527 private function updateMailAttachmentFields(string $relative_path, ResourceCollectionIdentification $rcid): void
528 {
529 $mail_ids = $this->mailIdsOfPath($relative_path);
530 if ($mail_ids === []) {
531 return;
532 }
533
534 $db = $this->helper->getDatabase();
535 $db->manipulateF(
536 'UPDATE mail SET attachments = %s WHERE ' . $db->in('mail_id', $mail_ids, false, ilDBConstants::T_INTEGER),
538 [$rcid->serialize()]
539 );
540 }
541
542 private function markPathAsSkipped(string $relative_path): void
543 {
544 $this->helper->getDatabase()->manipulateF(
545 'UPDATE mail_attachment SET rcid = %s WHERE path = %s',
547 [self::SKIPPED_MARKER, $relative_path]
548 );
549 }
550
551 private function resolveAdminInteraction(Environment $environment): ?AdminInteraction
552 {
553 $io = $environment->getResource(Environment::RESOURCE_ADMIN_INTERACTION);
554
555 return $io instanceof AdminInteraction ? $io : null;
556 }
557
558 private function count(string $key, int $amount = 1): void
559 {
560 $this->statistics[$key] = ($this->statistics[$key] ?? 0) + $amount;
561 }
562
566 private function info(string $message, array $context = []): void
567 {
568 $this->report('INFO', $message, $context);
569 }
570
574 private function warning(string $message, array $context = []): void
575 {
576 $this->report('WARNING', $message, $context);
577 }
578
582 private function error(string $message, array $context = [], ?Throwable $exception = null): void
583 {
584 if ($exception !== null) {
585 $context['exception'] = $exception::class . ': ' . $exception->getMessage() .
586 ' (' . $exception->getFile() . ':' . $exception->getLine() . ')';
587 }
588
589 $this->report('ERROR', $message, $context);
590 }
591
595 private function report(string $level, string $message, array $context): void
596 {
597 $this->io?->inform(sprintf(
598 '[%s] Mail attachment migration %s: %s%s',
599 date('Y-m-d H:i:s'),
600 $level,
601 $message,
602 $context !== []
603 ? ' ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR)
604 : ''
605 ));
606 }
607}
$filename
Definition: buildRTE.php:78
Migrates mail attachments to the IRSS, the attachments of the most recently sent mails first,...
getPreconditions(Environment $environment)
Objectives the migration depend on.
step(Environment $environment)
Run one step of the migration.
report(string $level, string $message, array $context)
prepare(Environment $environment)
Prepare the migration by means of some environment.
migrateSentAttachmentDirectories()
Sent and received mails share one directory per sending process.
getDefaultAmountOfStepsPerRun()
Tell the default amount of steps to be executed for one run of the migration.
updateMailAttachmentFields(string $relative_path, ResourceCollectionIdentification $rcid)
migratePoolFilenamesToCollection(array $filenames, int $user_id, string $mail_path, int $mail_id)
Pool files are copied, not moved: The same pool file may be referenced by several drafts and stays av...
copyDirectoryToCollection(string $absolute_path, array $filenames, int $owner_id)
Files stay on disk until the collection is stored and the mail rows point at it.
discardResources(array $resource_ids)
Copies of a directory that could not be migrated completely are removed, the next run copies it again...
migrateSerializedMail(int $mail_id, int $user_id, string $raw_attachments, string $mail_path, bool $references_pool)
error(string $message, array $context=[], ?Throwable $exception=null)
getRemainingAmountOfSteps()
Count up how many "things" need to be migrated.
migrateSerializedMailAttachments()
Drafts and scheduled mails reference files of the user's attachment pool.
markCollectionResourcesConfidential(ResourceCollectionIdentification $rcid)
assignRcidToPath(string $relative_path, ResourceCollectionIdentification $rcid)
Class ilDBConstants.
This class attempt to achieve a set of database update steps.
const SYSTEM_USER_ID
This file contains constants for PHPStan analyis, see: https://phpstan.org/config-reference#constants...
Definition: constants.php:26
This defines ways in which objectives may interact with admins during the setup.
An environment holds resources to be used in the setup process.
Definition: Environment.php:28
getResource(string $id)
Consumers of this method should check if the result is what they expect, e.g.
A migration is a potentially long lasting operation that can be broken into discrete steps.
Definition: Migration.php:29
foreach($_POST as $key=> $value) $res