xref: /webtrees/app/Tree.php (revision 5ede9153b02ad2bb8d54e5c5c34a4783925994bd)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2019 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees;
21
22use Closure;
23use Fisharebest\Flysystem\Adapter\ChrootAdapter;
24use Fisharebest\Webtrees\Contracts\UserInterface;
25use Fisharebest\Webtrees\Functions\FunctionsExport;
26use Fisharebest\Webtrees\Services\PendingChangesService;
27use Illuminate\Database\Capsule\Manager as DB;
28use Illuminate\Database\Query\Expression;
29use Illuminate\Support\Collection;
30use Illuminate\Support\Str;
31use InvalidArgumentException;
32use League\Flysystem\Filesystem;
33use League\Flysystem\FilesystemInterface;
34use Psr\Http\Message\StreamInterface;
35use stdClass;
36
37use function app;
38use function date;
39use function strtoupper;
40
41/**
42 * Provide an interface to the wt_gedcom table.
43 */
44class Tree
45{
46    private const RESN_PRIVACY = [
47        'none'         => Auth::PRIV_PRIVATE,
48        'privacy'      => Auth::PRIV_USER,
49        'confidential' => Auth::PRIV_NONE,
50        'hidden'       => Auth::PRIV_HIDE,
51    ];
52
53    /** @var int The tree's ID number */
54    private $id;
55
56    /** @var string The tree's name */
57    private $name;
58
59    /** @var string The tree's title */
60    private $title;
61
62    /** @var int[] Default access rules for facts in this tree */
63    private $fact_privacy;
64
65    /** @var int[] Default access rules for individuals in this tree */
66    private $individual_privacy;
67
68    /** @var integer[][] Default access rules for individual facts in this tree */
69    private $individual_fact_privacy;
70
71    /** @var string[] Cached copy of the wt_gedcom_setting table. */
72    private $preferences = [];
73
74    /** @var string[][] Cached copy of the wt_user_gedcom_setting table. */
75    private $user_preferences = [];
76
77    /**
78     * Create a tree object.
79     *
80     * @param int    $id
81     * @param string $name
82     * @param string $title
83     */
84    public function __construct(int $id, string $name, string $title)
85    {
86        $this->id                      = $id;
87        $this->name                    = $name;
88        $this->title                   = $title;
89        $this->fact_privacy            = [];
90        $this->individual_privacy      = [];
91        $this->individual_fact_privacy = [];
92
93        // Load the privacy settings for this tree
94        $rows = DB::table('default_resn')
95            ->where('gedcom_id', '=', $this->id)
96            ->get();
97
98        foreach ($rows as $row) {
99            // Convert GEDCOM privacy restriction to a webtrees access level.
100            $row->resn = self::RESN_PRIVACY[$row->resn];
101
102            if ($row->xref !== null) {
103                if ($row->tag_type !== null) {
104                    $this->individual_fact_privacy[$row->xref][$row->tag_type] = $row->resn;
105                } else {
106                    $this->individual_privacy[$row->xref] = $row->resn;
107                }
108            } else {
109                $this->fact_privacy[$row->tag_type] = $row->resn;
110            }
111        }
112    }
113
114    /**
115     * A closure which will create a record from a database row.
116     *
117     * @return Closure
118     */
119    public static function rowMapper(): Closure
120    {
121        return static function (stdClass $row): Tree {
122            return new Tree((int) $row->tree_id, $row->tree_name, $row->tree_title);
123        };
124    }
125
126    /**
127     * Set the tree’s configuration settings.
128     *
129     * @param string $setting_name
130     * @param string $setting_value
131     *
132     * @return $this
133     */
134    public function setPreference(string $setting_name, string $setting_value): Tree
135    {
136        if ($setting_value !== $this->getPreference($setting_name)) {
137            DB::table('gedcom_setting')->updateOrInsert([
138                'gedcom_id'    => $this->id,
139                'setting_name' => $setting_name,
140            ], [
141                'setting_value' => $setting_value,
142            ]);
143
144            $this->preferences[$setting_name] = $setting_value;
145
146            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '"', $this);
147        }
148
149        return $this;
150    }
151
152    /**
153     * Get the tree’s configuration settings.
154     *
155     * @param string $setting_name
156     * @param string $default
157     *
158     * @return string
159     */
160    public function getPreference(string $setting_name, string $default = ''): string
161    {
162        if ($this->preferences === []) {
163            $this->preferences = DB::table('gedcom_setting')
164                ->where('gedcom_id', '=', $this->id)
165                ->pluck('setting_value', 'setting_name')
166                ->all();
167        }
168
169        return $this->preferences[$setting_name] ?? $default;
170    }
171
172    /**
173     * The name of this tree
174     *
175     * @return string
176     */
177    public function name(): string
178    {
179        return $this->name;
180    }
181
182    /**
183     * The title of this tree
184     *
185     * @return string
186     */
187    public function title(): string
188    {
189        return $this->title;
190    }
191
192    /**
193     * The fact-level privacy for this tree.
194     *
195     * @return int[]
196     */
197    public function getFactPrivacy(): array
198    {
199        return $this->fact_privacy;
200    }
201
202    /**
203     * The individual-level privacy for this tree.
204     *
205     * @return int[]
206     */
207    public function getIndividualPrivacy(): array
208    {
209        return $this->individual_privacy;
210    }
211
212    /**
213     * The individual-fact-level privacy for this tree.
214     *
215     * @return int[][]
216     */
217    public function getIndividualFactPrivacy(): array
218    {
219        return $this->individual_fact_privacy;
220    }
221
222    /**
223     * Set the tree’s user-configuration settings.
224     *
225     * @param UserInterface $user
226     * @param string        $setting_name
227     * @param string        $setting_value
228     *
229     * @return $this
230     */
231    public function setUserPreference(UserInterface $user, string $setting_name, string $setting_value): Tree
232    {
233        if ($this->getUserPreference($user, $setting_name) !== $setting_value) {
234            // Update the database
235            DB::table('user_gedcom_setting')->updateOrInsert([
236                'gedcom_id'    => $this->id(),
237                'user_id'      => $user->id(),
238                'setting_name' => $setting_name,
239            ], [
240                'setting_value' => $setting_value,
241            ]);
242
243            // Update the cache
244            $this->user_preferences[$user->id()][$setting_name] = $setting_value;
245            // Audit log of changes
246            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '" for user "' . $user->userName() . '"', $this);
247        }
248
249        return $this;
250    }
251
252    /**
253     * Get the tree’s user-configuration settings.
254     *
255     * @param UserInterface $user
256     * @param string        $setting_name
257     * @param string        $default
258     *
259     * @return string
260     */
261    public function getUserPreference(UserInterface $user, string $setting_name, string $default = ''): string
262    {
263        // There are lots of settings, and we need to fetch lots of them on every page
264        // so it is quicker to fetch them all in one go.
265        if (!array_key_exists($user->id(), $this->user_preferences)) {
266            $this->user_preferences[$user->id()] = DB::table('user_gedcom_setting')
267                ->where('user_id', '=', $user->id())
268                ->where('gedcom_id', '=', $this->id)
269                ->pluck('setting_value', 'setting_name')
270                ->all();
271        }
272
273        return $this->user_preferences[$user->id()][$setting_name] ?? $default;
274    }
275
276    /**
277     * The ID of this tree
278     *
279     * @return int
280     */
281    public function id(): int
282    {
283        return $this->id;
284    }
285
286    /**
287     * Can a user accept changes for this tree?
288     *
289     * @param UserInterface $user
290     *
291     * @return bool
292     */
293    public function canAcceptChanges(UserInterface $user): bool
294    {
295        return Auth::isModerator($this, $user);
296    }
297
298    /**
299     * Are there any pending edits for this tree, than need reviewing by a moderator.
300     *
301     * @return bool
302     */
303    public function hasPendingEdit(): bool
304    {
305        return DB::table('change')
306            ->where('gedcom_id', '=', $this->id)
307            ->where('status', '=', 'pending')
308            ->exists();
309    }
310
311    /**
312     * Delete everything relating to a tree
313     *
314     * @return void
315     */
316    public function delete(): void
317    {
318        // If this is the default tree, then unset it
319        if (Site::getPreference('DEFAULT_GEDCOM') === $this->name) {
320            Site::setPreference('DEFAULT_GEDCOM', '');
321        }
322
323        $this->deleteGenealogyData(false);
324
325        DB::table('block_setting')
326            ->join('block', 'block.block_id', '=', 'block_setting.block_id')
327            ->where('gedcom_id', '=', $this->id)
328            ->delete();
329        DB::table('block')->where('gedcom_id', '=', $this->id)->delete();
330        DB::table('user_gedcom_setting')->where('gedcom_id', '=', $this->id)->delete();
331        DB::table('gedcom_setting')->where('gedcom_id', '=', $this->id)->delete();
332        DB::table('module_privacy')->where('gedcom_id', '=', $this->id)->delete();
333        DB::table('hit_counter')->where('gedcom_id', '=', $this->id)->delete();
334        DB::table('default_resn')->where('gedcom_id', '=', $this->id)->delete();
335        DB::table('gedcom_chunk')->where('gedcom_id', '=', $this->id)->delete();
336        DB::table('log')->where('gedcom_id', '=', $this->id)->delete();
337        DB::table('gedcom')->where('gedcom_id', '=', $this->id)->delete();
338    }
339
340    /**
341     * Delete all the genealogy data from a tree - in preparation for importing
342     * new data. Optionally retain the media data, for when the user has been
343     * editing their data offline using an application which deletes (or does not
344     * support) media data.
345     *
346     * @param bool $keep_media
347     *
348     * @return void
349     */
350    public function deleteGenealogyData(bool $keep_media): void
351    {
352        DB::table('gedcom_chunk')->where('gedcom_id', '=', $this->id)->delete();
353        DB::table('individuals')->where('i_file', '=', $this->id)->delete();
354        DB::table('families')->where('f_file', '=', $this->id)->delete();
355        DB::table('sources')->where('s_file', '=', $this->id)->delete();
356        DB::table('other')->where('o_file', '=', $this->id)->delete();
357        DB::table('places')->where('p_file', '=', $this->id)->delete();
358        DB::table('placelinks')->where('pl_file', '=', $this->id)->delete();
359        DB::table('name')->where('n_file', '=', $this->id)->delete();
360        DB::table('dates')->where('d_file', '=', $this->id)->delete();
361        DB::table('change')->where('gedcom_id', '=', $this->id)->delete();
362
363        if ($keep_media) {
364            DB::table('link')->where('l_file', '=', $this->id)
365                ->where('l_type', '<>', 'OBJE')
366                ->delete();
367        } else {
368            DB::table('link')->where('l_file', '=', $this->id)->delete();
369            DB::table('media_file')->where('m_file', '=', $this->id)->delete();
370            DB::table('media')->where('m_file', '=', $this->id)->delete();
371        }
372    }
373
374    /**
375     * Export the tree to a GEDCOM file
376     *
377     * @param resource $stream
378     *
379     * @return void
380     */
381    public function exportGedcom($stream): void
382    {
383        $buffer = FunctionsExport::reformatRecord(FunctionsExport::gedcomHeader($this, 'UTF-8'));
384
385        $union_families = DB::table('families')
386            ->where('f_file', '=', $this->id)
387            ->select(['f_gedcom AS gedcom', 'f_id AS xref', new Expression('LENGTH(f_id) AS len'), new Expression('2 AS n')]);
388
389        $union_sources = DB::table('sources')
390            ->where('s_file', '=', $this->id)
391            ->select(['s_gedcom AS gedcom', 's_id AS xref', new Expression('LENGTH(s_id) AS len'), new Expression('3 AS n')]);
392
393        $union_other = DB::table('other')
394            ->where('o_file', '=', $this->id)
395            ->whereNotIn('o_type', [Header::RECORD_TYPE, 'TRLR'])
396            ->select(['o_gedcom AS gedcom', 'o_id AS xref', new Expression('LENGTH(o_id) AS len'), new Expression('4 AS n')]);
397
398        $union_media = DB::table('media')
399            ->where('m_file', '=', $this->id)
400            ->select(['m_gedcom AS gedcom', 'm_id AS xref', new Expression('LENGTH(m_id) AS len'), new Expression('5 AS n')]);
401
402        DB::table('individuals')
403            ->where('i_file', '=', $this->id)
404            ->select(['i_gedcom AS gedcom', 'i_id AS xref', new Expression('LENGTH(i_id) AS len'), new Expression('1 AS n')])
405            ->union($union_families)
406            ->union($union_sources)
407            ->union($union_other)
408            ->union($union_media)
409            ->orderBy('n')
410            ->orderBy('len')
411            ->orderBy('xref')
412            ->chunk(1000, static function (Collection $rows) use ($stream, &$buffer): void {
413                foreach ($rows as $row) {
414                    $buffer .= FunctionsExport::reformatRecord($row->gedcom);
415                    if (strlen($buffer) > 65535) {
416                        fwrite($stream, $buffer);
417                        $buffer = '';
418                    }
419                }
420            });
421
422        fwrite($stream, $buffer . '0 TRLR' . Gedcom::EOL);
423    }
424
425    /**
426     * Import data from a gedcom file into this tree.
427     *
428     * @param StreamInterface $stream   The GEDCOM file.
429     * @param string          $filename The preferred filename, for export/download.
430     *
431     * @return void
432     */
433    public function importGedcomFile(StreamInterface $stream, string $filename): void
434    {
435        // Read the file in blocks of roughly 64K. Ensure that each block
436        // contains complete gedcom records. This will ensure we don’t split
437        // multi-byte characters, as well as simplifying the code to import
438        // each block.
439
440        $file_data = '';
441
442        $this->deleteGenealogyData((bool) $this->getPreference('keep_media'));
443        $this->setPreference('gedcom_filename', $filename);
444        $this->setPreference('imported', '0');
445
446        while (!$stream->eof()) {
447            $file_data .= $stream->read(65536);
448            // There is no strrpos() function that searches for substrings :-(
449            for ($pos = strlen($file_data) - 1; $pos > 0; --$pos) {
450                if ($file_data[$pos] === '0' && ($file_data[$pos - 1] === "\n" || $file_data[$pos - 1] === "\r")) {
451                    // We’ve found the last record boundary in this chunk of data
452                    break;
453                }
454            }
455            if ($pos) {
456                DB::table('gedcom_chunk')->insert([
457                    'gedcom_id'  => $this->id,
458                    'chunk_data' => substr($file_data, 0, $pos),
459                ]);
460
461                $file_data = substr($file_data, $pos);
462            }
463        }
464        DB::table('gedcom_chunk')->insert([
465            'gedcom_id'  => $this->id,
466            'chunk_data' => $file_data,
467        ]);
468
469        $stream->close();
470    }
471
472    /**
473     * Create a new record from GEDCOM data.
474     *
475     * @param string $gedcom
476     *
477     * @return GedcomRecord|Individual|Family|Note|Source|Repository|Media|Submitter|Submission
478     * @throws InvalidArgumentException
479     */
480    public function createRecord(string $gedcom): GedcomRecord
481    {
482        if (!Str::startsWith($gedcom, '0 @@ ')) {
483            throw new InvalidArgumentException('GedcomRecord::createRecord(' . $gedcom . ') does not begin 0 @@');
484        }
485
486        $xref   = $this->getNewXref();
487        $gedcom = '0 @' . $xref . '@ ' . Str::after($gedcom, '0 @@ ');
488
489        // Create a change record
490        $today = strtoupper(date('d M Y'));
491        $now   = date('H:i:s');
492        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
493
494        // Create a pending change
495        DB::table('change')->insert([
496            'gedcom_id'  => $this->id,
497            'xref'       => $xref,
498            'old_gedcom' => '',
499            'new_gedcom' => $gedcom,
500            'user_id'    => Auth::id(),
501        ]);
502
503        // Accept this pending change
504        if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS)) {
505            $record = new GedcomRecord($xref, $gedcom, null, $this);
506
507            app(PendingChangesService::class)->acceptRecord($record);
508
509            return $record;
510        }
511
512        return new GedcomRecord($xref, '', $gedcom, $this);
513    }
514
515    /**
516     * Generate a new XREF, unique across all family trees
517     *
518     * @return string
519     */
520    public function getNewXref(): string
521    {
522        // Lock the row, so that only one new XREF may be generated at a time.
523        DB::table('site_setting')
524            ->where('setting_name', '=', 'next_xref')
525            ->lockForUpdate()
526            ->get();
527
528        $prefix = 'X';
529
530        $increment = 1.0;
531        do {
532            $num = (int) Site::getPreference('next_xref') + (int) $increment;
533
534            // This exponential increment allows us to scan over large blocks of
535            // existing data in a reasonable time.
536            $increment *= 1.01;
537
538            $xref = $prefix . $num;
539
540            // Records may already exist with this sequence number.
541            $already_used =
542                DB::table('individuals')->where('i_id', '=', $xref)->exists() ||
543                DB::table('families')->where('f_id', '=', $xref)->exists() ||
544                DB::table('sources')->where('s_id', '=', $xref)->exists() ||
545                DB::table('media')->where('m_id', '=', $xref)->exists() ||
546                DB::table('other')->where('o_id', '=', $xref)->exists() ||
547                DB::table('change')->where('xref', '=', $xref)->exists();
548        } while ($already_used);
549
550        Site::setPreference('next_xref', (string) $num);
551
552        return $xref;
553    }
554
555    /**
556     * Create a new family from GEDCOM data.
557     *
558     * @param string $gedcom
559     *
560     * @return Family
561     * @throws InvalidArgumentException
562     */
563    public function createFamily(string $gedcom): GedcomRecord
564    {
565        if (!Str::startsWith($gedcom, '0 @@ FAM')) {
566            throw new InvalidArgumentException('GedcomRecord::createFamily(' . $gedcom . ') does not begin 0 @@ FAM');
567        }
568
569        $xref   = $this->getNewXref();
570        $gedcom = '0 @' . $xref . '@ FAM' . Str::after($gedcom, '0 @@ FAM');
571
572        // Create a change record
573        $today = strtoupper(date('d M Y'));
574        $now   = date('H:i:s');
575        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
576
577        // Create a pending change
578        DB::table('change')->insert([
579            'gedcom_id'  => $this->id,
580            'xref'       => $xref,
581            'old_gedcom' => '',
582            'new_gedcom' => $gedcom,
583            'user_id'    => Auth::id(),
584        ]);
585
586        // Accept this pending change
587        if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') {
588            $record = new Family($xref, $gedcom, null, $this);
589
590            app(PendingChangesService::class)->acceptRecord($record);
591
592            return $record;
593        }
594
595        return new Family($xref, '', $gedcom, $this);
596    }
597
598    /**
599     * Create a new individual from GEDCOM data.
600     *
601     * @param string $gedcom
602     *
603     * @return Individual
604     * @throws InvalidArgumentException
605     */
606    public function createIndividual(string $gedcom): GedcomRecord
607    {
608        if (!Str::startsWith($gedcom, '0 @@ INDI')) {
609            throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI');
610        }
611
612        $xref   = $this->getNewXref();
613        $gedcom = '0 @' . $xref . '@ INDI' . Str::after($gedcom, '0 @@ INDI');
614
615        // Create a change record
616        $today = strtoupper(date('d M Y'));
617        $now   = date('H:i:s');
618        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
619
620        // Create a pending change
621        DB::table('change')->insert([
622            'gedcom_id'  => $this->id,
623            'xref'       => $xref,
624            'old_gedcom' => '',
625            'new_gedcom' => $gedcom,
626            'user_id'    => Auth::id(),
627        ]);
628
629        // Accept this pending change
630        if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') {
631            $record = new Individual($xref, $gedcom, null, $this);
632
633            app(PendingChangesService::class)->acceptRecord($record);
634
635            return $record;
636        }
637
638        return new Individual($xref, '', $gedcom, $this);
639    }
640
641    /**
642     * Create a new media object from GEDCOM data.
643     *
644     * @param string $gedcom
645     *
646     * @return Media
647     * @throws InvalidArgumentException
648     */
649    public function createMediaObject(string $gedcom): Media
650    {
651        if (!Str::startsWith($gedcom, '0 @@ OBJE')) {
652            throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ OBJE');
653        }
654
655        $xref   = $this->getNewXref();
656        $gedcom = '0 @' . $xref . '@ OBJE' . Str::after($gedcom, '0 @@ OBJE');
657
658        // Create a change record
659        $today = strtoupper(date('d M Y'));
660        $now   = date('H:i:s');
661        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
662
663        // Create a pending change
664        DB::table('change')->insert([
665            'gedcom_id'  => $this->id,
666            'xref'       => $xref,
667            'old_gedcom' => '',
668            'new_gedcom' => $gedcom,
669            'user_id'    => Auth::id(),
670        ]);
671
672        // Accept this pending change
673        if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') {
674            $record = new Media($xref, $gedcom, null, $this);
675
676            app(PendingChangesService::class)->acceptRecord($record);
677
678            return $record;
679        }
680
681        return new Media($xref, '', $gedcom, $this);
682    }
683
684    /**
685     * What is the most significant individual in this tree.
686     *
687     * @param UserInterface $user
688     * @param string        $xref
689     *
690     * @return Individual
691     */
692    public function significantIndividual(UserInterface $user, $xref = ''): Individual
693    {
694        if ($xref === '') {
695            $individual = null;
696        } else {
697            $individual = Individual::getInstance($xref, $this);
698
699            if ($individual === null) {
700                $family = Family::getInstance($xref, $this);
701
702                if ($family instanceof Family) {
703                    $individual = $family->spouses()->first() ?? $family->children()->first();
704                }
705            }
706        }
707
708        if ($individual === null && $this->getUserPreference($user, User::PREF_TREE_DEFAULT_XREF) !== '') {
709            $individual = Individual::getInstance($this->getUserPreference($user, User::PREF_TREE_DEFAULT_XREF), $this);
710        }
711
712        if ($individual === null && $this->getUserPreference($user, User::PREF_TREE_ACCOUNT_XREF) !== '') {
713            $individual = Individual::getInstance($this->getUserPreference($user, User::PREF_TREE_ACCOUNT_XREF), $this);
714        }
715
716        if ($individual === null && $this->getPreference('PEDIGREE_ROOT_ID') !== '') {
717            $individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this);
718        }
719        if ($individual === null) {
720            $xref = (string) DB::table('individuals')
721                ->where('i_file', '=', $this->id())
722                ->min('i_id');
723
724            $individual = Individual::getInstance($xref, $this);
725        }
726        if ($individual === null) {
727            // always return a record
728            $individual = new Individual('I', '0 @I@ INDI', null, $this);
729        }
730
731        return $individual;
732    }
733
734    /**
735     * Where do we store our media files.
736     *
737     * @param FilesystemInterface $data_filesystem
738     *
739     * @return FilesystemInterface
740     */
741    public function mediaFilesystem(FilesystemInterface $data_filesystem): FilesystemInterface
742    {
743        $media_dir = $this->getPreference('MEDIA_DIRECTORY', 'media/');
744        $adapter   = new ChrootAdapter($data_filesystem, $media_dir);
745
746        return new Filesystem($adapter);
747    }
748}
749