xref: /webtrees/app/Module/SiteMapModule.php (revision 659aa375532f239df7a372e3db80d955653ef965)
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\Module;
21
22use Fig\Http\Message\StatusCodeInterface;
23use Fisharebest\Webtrees\Auth;
24use Fisharebest\Webtrees\Carbon;
25use Fisharebest\Webtrees\Exceptions\HttpNotFoundException;
26use Fisharebest\Webtrees\FlashMessages;
27use Fisharebest\Webtrees\GedcomRecord;
28use Fisharebest\Webtrees\Html;
29use Fisharebest\Webtrees\I18N;
30use Fisharebest\Webtrees\Individual;
31use Fisharebest\Webtrees\Media;
32use Fisharebest\Webtrees\Note;
33use Fisharebest\Webtrees\Repository;
34use Fisharebest\Webtrees\Services\TreeService;
35use Fisharebest\Webtrees\Source;
36use Fisharebest\Webtrees\Tree;
37use Illuminate\Database\Capsule\Manager as DB;
38use Illuminate\Database\Query\Expression;
39use Illuminate\Support\Collection;
40use Psr\Http\Message\ResponseInterface;
41use Psr\Http\Message\ServerRequestInterface;
42
43use function redirect;
44use function view;
45
46/**
47 * Class SiteMapModule
48 */
49class SiteMapModule extends AbstractModule implements ModuleConfigInterface
50{
51    use ModuleConfigTrait;
52
53    private const RECORDS_PER_VOLUME = 500; // Keep sitemap files small, for memory, CPU and max_allowed_packet limits.
54    private const CACHE_LIFE         = 1209600; // Two weeks
55
56    /** @var TreeService */
57    private $tree_service;
58
59    /**
60     * TreesMenuModule constructor.
61     *
62     * @param TreeService $tree_service
63     */
64    public function __construct(TreeService $tree_service)
65    {
66        $this->tree_service = $tree_service;
67    }
68
69    /**
70     * A sentence describing what this module does.
71     *
72     * @return string
73     */
74    public function description(): string
75    {
76        /* I18N: Description of the “Sitemaps” module */
77        return I18N::translate('Generate sitemap files for search engines.');
78    }
79
80    /**
81     * Should this module be enabled when it is first installed?
82     *
83     * @return bool
84     */
85    public function isEnabledByDefault(): bool
86    {
87        return false;
88    }
89
90    /**
91     * @param ServerRequestInterface $request
92     *
93     * @return ResponseInterface
94     */
95    public function getAdminAction(ServerRequestInterface $request): ResponseInterface
96    {
97        $this->layout = 'layouts/administration';
98
99        $sitemap_url = route('module', [
100            'module' => $this->name(),
101            'action' => 'Index',
102        ]);
103
104        // This list comes from https://en.wikipedia.org/wiki/Sitemaps
105        $submit_urls = [
106            'Bing/Yahoo' => Html::url('https://www.bing.com/webmaster/ping.aspx', ['siteMap' => $sitemap_url]),
107            'Google'     => Html::url('https://www.google.com/webmasters/tools/ping', ['sitemap' => $sitemap_url]),
108        ];
109
110        return $this->viewResponse('modules/sitemap/config', [
111            'all_trees'   => $this->tree_service->all(),
112            'sitemap_url' => $sitemap_url,
113            'submit_urls' => $submit_urls,
114            'title'       => $this->title(),
115        ]);
116    }
117
118    /**
119     * How should this module be identified in the control panel, etc.?
120     *
121     * @return string
122     */
123    public function title(): string
124    {
125        /* I18N: Name of a module - see http://en.wikipedia.org/wiki/Sitemaps */
126        return I18N::translate('Sitemaps');
127    }
128
129    /**
130     * @param ServerRequestInterface $request
131     *
132     * @return ResponseInterface
133     */
134    public function postAdminAction(ServerRequestInterface $request): ResponseInterface
135    {
136        $params = (array) $request->getParsedBody();
137
138        foreach ($this->tree_service->all() as $tree) {
139            $include_in_sitemap = (bool) ($params['sitemap' . $tree->id()] ?? false);
140            $tree->setPreference('include_in_sitemap', (string) $include_in_sitemap);
141        }
142
143        FlashMessages::addMessage(I18N::translate('The preferences for the module “%s” have been updated.', $this->title()), 'success');
144
145        return redirect($this->getConfigLink());
146    }
147
148    /**
149     * @param ServerRequestInterface $request
150     *
151     * @return ResponseInterface
152     */
153    public function getIndexAction(ServerRequestInterface $request): ResponseInterface
154    {
155        $timestamp = (int) $this->getPreference('sitemap.timestamp');
156
157        // Which trees have sitemaps enabled?
158        $tree_ids = $this->tree_service->all()->filter(static function (Tree $tree): bool {
159            return $tree->getPreference('include_in_sitemap') === '1';
160        })->map(static function (Tree $tree): int {
161            return $tree->id();
162        });
163
164        if ($timestamp > Carbon::now()->subSeconds(self::CACHE_LIFE)->unix()) {
165            $content = $this->getPreference('sitemap.xml');
166        } else {
167            $count_individuals = DB::table('individuals')
168                ->whereIn('i_file', $tree_ids)
169                ->groupBy(['i_file'])
170                ->select([new Expression('COUNT(*) AS total'), 'i_file'])
171                ->pluck('total', 'i_file');
172
173            $count_media = DB::table('media')
174                ->whereIn('m_file', $tree_ids)
175                ->groupBy(['m_file'])
176                ->select([new Expression('COUNT(*) AS total'), 'm_file'])
177                ->pluck('total', 'm_file');
178
179            $count_notes = DB::table('other')
180                ->whereIn('o_file', $tree_ids)
181                ->where('o_type', '=', 'NOTE')
182                ->groupBy(['o_file'])
183                ->select([new Expression('COUNT(*) AS total'), 'o_file'])
184                ->pluck('total', 'o_file');
185
186            $count_repositories = DB::table('other')
187                ->whereIn('o_file', $tree_ids)
188                ->where('o_type', '=', 'REPO')
189                ->groupBy(['o_file'])
190                ->select([new Expression('COUNT(*) AS total'), 'o_file'])
191                ->pluck('total', 'o_file');
192
193            $count_sources = DB::table('sources')
194                ->whereIn('s_file', $tree_ids)
195                ->groupBy(['s_file'])
196                ->select([new Expression('COUNT(*) AS total'), 's_file'])
197                ->pluck('total', 's_file');
198
199            $content = view('modules/sitemap/sitemap-index.xml', [
200                'all_trees'          => $this->tree_service->all(),
201                'count_individuals'  => $count_individuals,
202                'count_media'        => $count_media,
203                'count_notes'        => $count_notes,
204                'count_repositories' => $count_repositories,
205                'count_sources'      => $count_sources,
206                'last_mod'           => date('Y-m-d'),
207                'records_per_volume' => self::RECORDS_PER_VOLUME,
208            ]);
209
210            $this->setPreference('sitemap.xml', $content);
211        }
212
213        return response($content, StatusCodeInterface::STATUS_OK, [
214            'Content-Type' => 'application/xml',
215        ]);
216    }
217
218    /**
219     * @param ServerRequestInterface $request
220     *
221     * @return ResponseInterface
222     */
223    public function getFileAction(ServerRequestInterface $request): ResponseInterface
224    {
225        $file = $request->getQueryParams()['file'];
226
227        if (!preg_match('/^(\d+)-([imnrs])-(\d+)$/', $file, $match)) {
228            throw new HttpNotFoundException('Bad sitemap file');
229        }
230
231        $timestamp   = (int) $this->getPreference('sitemap-' . $file . '.timestamp');
232        $expiry_time = Carbon::now()->subSeconds(self::CACHE_LIFE)->unix();
233
234        if ($timestamp > $expiry_time) {
235            $content = $this->getPreference('sitemap-' . $file . '.xml');
236        } else {
237            $tree = $this->tree_service->find((int) $match[1]);
238
239            if ($tree->getPreference('include_in_sitemap') !== '1') {
240                throw new HttpNotFoundException();
241            }
242
243            $records = $this->sitemapRecords($tree, $match[2], self::RECORDS_PER_VOLUME, self::RECORDS_PER_VOLUME * $match[3]);
244
245            $content = view('modules/sitemap/sitemap-file.xml', ['records' => $records]);
246
247            $this->setPreference('sitemap.xml', $content);
248        }
249
250        return response($content, StatusCodeInterface::STATUS_OK, [
251            'Content-Type' => 'application/xml',
252        ]);
253    }
254
255    /**
256     * @param Tree   $tree
257     * @param string $type
258     * @param int    $limit
259     * @param int    $offset
260     *
261     * @return Collection<GedcomRecord>
262     */
263    private function sitemapRecords(Tree $tree, string $type, int $limit, int $offset): Collection
264    {
265        switch ($type) {
266            case 'i':
267                $records = $this->sitemapIndividuals($tree, $limit, $offset);
268                break;
269
270            case 'm':
271                $records = $this->sitemapMedia($tree, $limit, $offset);
272                break;
273
274            case 'n':
275                $records = $this->sitemapNotes($tree, $limit, $offset);
276                break;
277
278            case 'r':
279                $records = $this->sitemapRepositories($tree, $limit, $offset);
280                break;
281
282            case 's':
283                $records = $this->sitemapSources($tree, $limit, $offset);
284                break;
285
286            default:
287                throw new HttpNotFoundException('Invalid record type: ' . $type);
288        }
289
290        // Skip private records.
291        $records = $records->filter(static function (GedcomRecord $record): bool {
292            return $record->canShow(Auth::PRIV_PRIVATE);
293        });
294
295        return $records;
296    }
297
298    /**
299     * @param Tree $tree
300     * @param int  $limit
301     * @param int  $offset
302     *
303     * @return Collection<Individual>
304     */
305    private function sitemapIndividuals(Tree $tree, int $limit, int $offset): Collection
306    {
307        return DB::table('individuals')
308            ->where('i_file', '=', $tree->id())
309            ->orderBy('i_id')
310            ->skip($offset)
311            ->take($limit)
312            ->get()
313            ->map(Individual::rowMapper($tree));
314    }
315
316    /**
317     * @param Tree $tree
318     * @param int  $limit
319     * @param int  $offset
320     *
321     * @return Collection<Media>
322     */
323    private function sitemapMedia(Tree $tree, int $limit, int $offset): Collection
324    {
325        return DB::table('media')
326            ->where('m_file', '=', $tree->id())
327            ->orderBy('m_id')
328            ->skip($offset)
329            ->take($limit)
330            ->get()
331            ->map(Media::rowMapper($tree));
332    }
333
334    /**
335     * @param Tree $tree
336     * @param int  $limit
337     * @param int  $offset
338     *
339     * @return Collection<Note>
340     */
341    private function sitemapNotes(Tree $tree, int $limit, int $offset): Collection
342    {
343        return DB::table('other')
344            ->where('o_file', '=', $tree->id())
345            ->where('o_type', '=', 'NOTE')
346            ->orderBy('o_id')
347            ->skip($offset)
348            ->take($limit)
349            ->get()
350            ->map(Note::rowMapper($tree));
351    }
352
353    /**
354     * @param Tree $tree
355     * @param int  $limit
356     * @param int  $offset
357     *
358     * @return Collection<Repository>
359     */
360    private function sitemapRepositories(Tree $tree, int $limit, int $offset): Collection
361    {
362        return DB::table('other')
363            ->where('o_file', '=', $tree->id())
364            ->where('o_type', '=', 'REPO')
365            ->orderBy('o_id')
366            ->skip($offset)
367            ->take($limit)
368            ->get()
369            ->map(Repository::rowMapper($tree));
370    }
371
372    /**
373     * @param Tree $tree
374     * @param int  $limit
375     * @param int  $offset
376     *
377     * @return Collection<Source>
378     */
379    private function sitemapSources(Tree $tree, int $limit, int $offset): Collection
380    {
381        return DB::table('sources')
382            ->where('s_file', '=', $tree->id())
383            ->orderBy('s_id')
384            ->skip($offset)
385            ->take($limit)
386            ->get()
387            ->map(Source::rowMapper($tree));
388    }
389}
390