xref: /webtrees/app/Module/SiteMapModule.php (revision b2ce94c6833c25934b40a7e6bc15985d50b5f42a)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2018 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16namespace Fisharebest\Webtrees\Module;
17
18use Fisharebest\Webtrees\Database;
19use Fisharebest\Webtrees\FlashMessages;
20use Fisharebest\Webtrees\GedcomRecord;
21use Fisharebest\Webtrees\Html;
22use Fisharebest\Webtrees\I18N;
23use Fisharebest\Webtrees\Individual;
24use Fisharebest\Webtrees\Media;
25use Fisharebest\Webtrees\Note;
26use Fisharebest\Webtrees\Repository;
27use Fisharebest\Webtrees\Source;
28use Fisharebest\Webtrees\Tree;
29use Symfony\Component\HttpFoundation\RedirectResponse;
30use Symfony\Component\HttpFoundation\Request;
31use Symfony\Component\HttpFoundation\Response;
32use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
33
34/**
35 * Class SiteMapModule
36 */
37class SiteMapModule extends AbstractModule implements ModuleConfigInterface
38{
39    const RECORDS_PER_VOLUME = 500; // Keep sitemap files small, for memory, CPU and max_allowed_packet limits.
40    const CACHE_LIFE         = 1209600; // Two weeks
41
42    /**
43     * How should this module be labelled on tabs, menus, etc.?
44     *
45     * @return string
46     */
47    public function getTitle(): string
48    {
49        /* I18N: Name of a module - see http://en.wikipedia.org/wiki/Sitemaps */
50        return I18N::translate('Sitemaps');
51    }
52
53    /**
54     * A sentence describing what this module does.
55     *
56     * @return string
57     */
58    public function getDescription(): string
59    {
60        /* I18N: Description of the “Sitemaps” module */
61        return I18N::translate('Generate sitemap files for search engines.');
62    }
63
64    /**
65     * The URL to a page where the user can modify the configuration of this module.
66     *
67     * @return string
68     */
69    public function getConfigLink(): string
70    {
71        return route('module', [
72            'module' => $this->getName(),
73            'action' => 'Admin',
74        ]);
75    }
76
77    /**
78     * @param Request $request
79     *
80     * @return Response
81     */
82    public function getAdminAction(Request $request): Response
83    {
84        $this->layout = 'layouts/administration';
85
86        $sitemap_url = route('module', [
87            'module' => 'sitemap',
88            'action' => 'Index',
89        ]);
90
91        // This list comes from http://en.wikipedia.org/wiki/Sitemaps
92        $submit_urls = [
93            'Bing/Yahoo' => Html::url('https://www.bing.com/webmaster/ping.aspx', ['siteMap' => $sitemap_url]),
94            'Google'     => Html::url('https://www.google.com/webmasters/tools/ping', ['sitemap' => $sitemap_url]),
95        ];
96
97        return $this->viewResponse('modules/sitemap/config', [
98            'all_trees'   => Tree::getAll(),
99            'sitemap_url' => $sitemap_url,
100            'submit_urls' => $submit_urls,
101            'title'       => $this->getTitle(),
102        ]);
103    }
104
105    /**
106     * @param Request $request
107     *
108     * @return RedirectResponse
109     */
110    public function postAdminAction(Request $request): RedirectResponse
111    {
112        foreach (Tree::getAll() as $tree) {
113            $include_in_sitemap = (bool)$request->get('sitemap' . $tree->getTreeId());
114            $tree->setPreference('include_in_sitemap', (string)$include_in_sitemap);
115        }
116
117        FlashMessages::addMessage(I18N::translate('The preferences for the module “%s” have been updated.', $this->getTitle()), 'success');
118
119        return new RedirectResponse($this->getConfigLink());
120    }
121
122    /**
123     * @param Request $request
124     *
125     * @return Response
126     */
127    public function getIndexAction(Request $request): Response
128    {
129        $timestamp = (int)$this->getPreference('sitemap.timestamp');
130
131        if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) {
132            $content = $this->getPreference('sitemap.xml');
133        } else {
134            $count_individuals = Database::prepare(
135                "SELECT i_file, COUNT(*) FROM `##individuals` GROUP BY i_file"
136            )->execute()->fetchAssoc();
137
138            $count_media = Database::prepare(
139                "SELECT m_file, COUNT(*) FROM `##media` GROUP BY m_file"
140            )->execute()->fetchAssoc();
141
142            $count_notes = Database::prepare(
143                "SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='NOTE' GROUP BY o_file"
144            )->execute()->fetchAssoc();
145
146            $count_repositories = Database::prepare(
147                "SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='REPO' GROUP BY o_file"
148            )->execute()->fetchAssoc();
149
150            $count_sources = Database::prepare(
151                "SELECT s_file, COUNT(*) FROM `##sources` GROUP BY s_file"
152            )->execute()->fetchAssoc();
153
154            $content = view('modules/sitemap/sitemap-index.xml', [
155                'all_trees'          => Tree::getAll(),
156                'count_individuals'  => $count_individuals,
157                'count_media'        => $count_media,
158                'count_notes'        => $count_notes,
159                'count_repositories' => $count_repositories,
160                'count_sources'      => $count_sources,
161                'last_mod'           => date('Y-m-d'),
162                'records_per_volume' => self::RECORDS_PER_VOLUME,
163            ]);
164
165            $this->setPreference('sitemap.xml', $content);
166        }
167
168        return new Response($content, Response::HTTP_OK, [
169            'Content-Type' => 'application/xml',
170        ]);
171    }
172
173    /**
174     * @param Request $request
175     *
176     * @return Response
177     */
178    public function getFileAction(Request $request): Response
179    {
180        $file = $request->get('file', '');
181
182        if (!preg_match('/^(\d+)-([imnrs])-(\d+)$/', $file, $match)) {
183            throw new NotFoundHttpException('Bad sitemap file');
184        }
185
186        $timestamp = (int)$this->getPreference('sitemap-' . $file . '.timestamp');
187
188        if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) {
189            $content = $this->getPreference('sitemap-' . $file . '.xml');
190        } else {
191            $tree = Tree::findById((int)$match[1]);
192
193            if ($tree === null) {
194                throw new NotFoundHttpException('No such tree');
195            }
196
197            $records = $this->sitemapRecords($tree, $match[2], self::RECORDS_PER_VOLUME, self::RECORDS_PER_VOLUME * $match[3]);
198
199            $content = view('modules/sitemap/sitemap-file.xml', ['records' => $records]);
200
201            $this->setPreference('sitemap.xml', $content);
202        }
203
204        return new Response($content, Response::HTTP_OK, [
205            'Content-Type' => 'application/xml',
206        ]);
207    }
208
209    /**
210     * @param Tree   $tree
211     * @param string $type
212     * @param int    $limit
213     * @param int    $offset
214     *
215     * @return array
216     */
217    private function sitemapRecords(Tree $tree, string $type, int $limit, int $offset): array
218    {
219        switch ($type) {
220            case 'i':
221                $records = $this->sitemapIndividuals($tree, $limit, $offset);
222                break;
223
224            case 'm':
225                $records = $this->sitemapMedia($tree, $limit, $offset);
226                break;
227
228            case 'n':
229                $records = $this->sitemapNotes($tree, $limit, $offset);
230                break;
231
232            case 'r':
233                $records = $this->sitemapRepositories($tree, $limit, $offset);
234                break;
235
236            case 's':
237                $records = $this->sitemapSources($tree, $limit, $offset);
238                break;
239
240            default:
241                throw new NotFoundHttpException('Invalid record type: ' . $type);
242        }
243
244        // Skip records that no longer exist.
245        $records = array_filter($records);
246
247        // Skip private records.
248        $records = array_filter($records, function (GedcomRecord $record): bool {
249            return $record->canShow();
250        });
251
252        return $records;
253    }
254
255    /**
256     * @param Tree $tree
257     * @param int  $limit
258     * @param int  $offset
259     *
260     * @return array
261     */
262    private function sitemapIndividuals(Tree $tree, int $limit, int $offset): array
263    {
264        $rows = Database::prepare(
265            "SELECT i_id AS xref, i_gedcom AS gedcom" .
266            " FROM `##individuals`" .
267            " WHERE i_file = :tree_id" .
268            " ORDER BY i_id" .
269            " LIMIT :limit OFFSET :offset"
270        )->execute([
271            'tree_id' => $tree->getTreeId(),
272            'limit'   => $limit,
273            'offset'  => $offset,
274        ])->fetchAll();
275
276        $records = [];
277
278        foreach ($rows as $row) {
279            $records[] = Individual::getInstance($row->xref, $tree, $row->gedcom);
280        }
281
282        return $records;
283    }
284
285    /**
286     * @param Tree $tree
287     * @param int  $limit
288     * @param int  $offset
289     *
290     * @return array
291     */
292    private function sitemapMedia(Tree $tree, int $limit, int $offset): array
293    {
294        $rows = Database::prepare(
295            "SELECT m_id AS xref, m_gedcom AS gedcom" .
296            " FROM `##media`" .
297            " WHERE m_file = :tree_id" .
298            " ORDER BY m_id" .
299            " LIMIT :limit OFFSET :offset"
300        )->execute([
301            'tree_id' => $tree->getTreeId(),
302            'limit'   => $limit,
303            'offset'  => $offset,
304        ])->fetchAll();
305
306        $records = [];
307
308        foreach ($rows as $row) {
309            $records[] = Media::getInstance($row->xref, $tree, $row->gedcom);
310        }
311
312        return $records;
313    }
314
315    /**
316     * @param Tree $tree
317     * @param int  $limit
318     * @param int  $offset
319     *
320     * @return array
321     */
322    private function sitemapNotes(Tree $tree, int $limit, int $offset): array
323    {
324        $rows = Database::prepare(
325            "SELECT o_id AS xref, o_gedcom AS gedcom" .
326            " FROM `##other`" .
327            " WHERE o_file = :tree_id AND o_type = 'NOTE'" .
328            " ORDER BY o_id" .
329            " LIMIT :limit OFFSET :offset"
330        )->execute([
331            'tree_id' => $tree->getTreeId(),
332            'limit'   => $limit,
333            'offset'  => $offset,
334        ])->fetchAll();
335
336        $records = [];
337
338        foreach ($rows as $row) {
339            $records[] = Note::getInstance($row->xref, $tree, $row->gedcom);
340        }
341
342        return $records;
343    }
344
345    /**
346     * @param Tree $tree
347     * @param int  $limit
348     * @param int  $offset
349     *
350     * @return array
351     */
352    private function sitemapRepositories(Tree $tree, int $limit, int $offset): array
353    {
354        $rows = Database::prepare(
355            "SELECT o_id AS xref, o_gedcom AS gedcom" .
356            " FROM `##other`" .
357            " WHERE o_file = :tree_id AND o_type = 'REPO'" .
358            " ORDER BY o_id" .
359            " LIMIT :limit OFFSET :offset"
360        )->execute([
361            'tree_id' => $tree->getTreeId(),
362            'limit'   => $limit,
363            'offset'  => $offset,
364        ])->fetchAll();
365
366        $records = [];
367
368        foreach ($rows as $row) {
369            $records[] = Repository::getInstance($row->xref, $tree, $row->gedcom);
370        }
371
372        return $records;
373    }
374
375    /**
376     * @param Tree $tree
377     * @param int  $limit
378     * @param int  $offset
379     *
380     * @return array
381     */
382    private function sitemapSources(Tree $tree, int $limit, int $offset): array
383    {
384        $rows = Database::prepare(
385            "SELECT s_id AS xref, s_gedcom AS gedcom" .
386            " FROM `##sources`" .
387            " WHERE s_file = :tree_id" .
388            " ORDER BY s_id" .
389            " LIMIT :limit OFFSET :offset"
390        )->execute([
391            'tree_id' => $tree->getTreeId(),
392            'limit'   => $limit,
393            'offset'  => $offset,
394        ])->fetchAll();
395
396        $records = [];
397
398        foreach ($rows as $row) {
399            $records[] = Source::getInstance($row->xref, $tree, $row->gedcom);
400        }
401
402        return $records;
403    }
404}
405