xref: /webtrees/app/Module/SiteMapModule.php (revision fa17fb661badda9494ff1a7acdc88293a3ff4572)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2019 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 */
16declare(strict_types=1);
17
18namespace Fisharebest\Webtrees\Module;
19
20use Fisharebest\Webtrees\FlashMessages;
21use Fisharebest\Webtrees\GedcomRecord;
22use Fisharebest\Webtrees\Html;
23use Fisharebest\Webtrees\I18N;
24use Fisharebest\Webtrees\Individual;
25use Fisharebest\Webtrees\Media;
26use Fisharebest\Webtrees\Note;
27use Fisharebest\Webtrees\Repository;
28use Fisharebest\Webtrees\Source;
29use Fisharebest\Webtrees\Tree;
30use Illuminate\Database\Capsule\Manager as DB;
31use Symfony\Component\HttpFoundation\RedirectResponse;
32use Symfony\Component\HttpFoundation\Request;
33use Symfony\Component\HttpFoundation\Response;
34use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
35
36/**
37 * Class SiteMapModule
38 */
39class SiteMapModule extends AbstractModule implements ModuleConfigInterface
40{
41    private const RECORDS_PER_VOLUME = 500; // Keep sitemap files small, for memory, CPU and max_allowed_packet limits.
42    private const CACHE_LIFE         = 1209600; // Two weeks
43
44    /**
45     * How should this module be labelled on tabs, menus, etc.?
46     *
47     * @return string
48     */
49    public function getTitle(): string
50    {
51        /* I18N: Name of a module - see http://en.wikipedia.org/wiki/Sitemaps */
52        return I18N::translate('Sitemaps');
53    }
54
55    /**
56     * A sentence describing what this module does.
57     *
58     * @return string
59     */
60    public function getDescription(): string
61    {
62        /* I18N: Description of the “Sitemaps” module */
63        return I18N::translate('Generate sitemap files for search engines.');
64    }
65
66    /**
67     * The URL to a page where the user can modify the configuration of this module.
68     *
69     * @return string
70     */
71    public function getConfigLink(): string
72    {
73        return route('module', [
74            'module' => $this->getName(),
75            'action' => 'Admin',
76        ]);
77    }
78
79    /**
80     * @return Response
81     */
82    public function getAdminAction(): 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->id());
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     * @return Response
124     */
125    public function getIndexAction(): Response
126    {
127        $timestamp = (int) $this->getPreference('sitemap.timestamp');
128
129        if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) {
130            $content = $this->getPreference('sitemap.xml');
131        } else {
132            $count_individuals = DB::table('individuals')
133                ->groupBy('i_file')
134                ->select([DB::raw('COUNT(*) AS total'), 'i_file'])
135                ->pluck('total', 'i_file');
136
137            $count_media = DB::table('media')
138                ->groupBy('m_file')
139                ->select([DB::raw('COUNT(*) AS total'), 'm_file'])
140                ->pluck('total', 'm_file');
141
142            $count_notes = DB::table('other')
143                ->where('o_type', '=', 'NOTE')
144                ->groupBy('o_file')
145                ->select([DB::raw('COUNT(*) AS total'), 'o_file'])
146                ->pluck('total', 'o_file');
147
148            $count_repositories = DB::table('other')
149                ->where('o_type', '=', 'REPO')
150                ->groupBy('o_file')
151                ->select([DB::raw('COUNT(*) AS total'), 'o_file'])
152                ->pluck('total', 'o_file');
153
154            $count_sources = DB::table('sources')
155                ->groupBy('s_file')
156                ->select([DB::raw('COUNT(*) AS total'), 's_file'])
157                ->pluck('total', 's_file');
158
159            $content = view('modules/sitemap/sitemap-index.xml', [
160                'all_trees'          => Tree::getAll(),
161                'count_individuals'  => $count_individuals,
162                'count_media'        => $count_media,
163                'count_notes'        => $count_notes,
164                'count_repositories' => $count_repositories,
165                'count_sources'      => $count_sources,
166                'last_mod'           => date('Y-m-d'),
167                'records_per_volume' => self::RECORDS_PER_VOLUME,
168            ]);
169
170            $this->setPreference('sitemap.xml', $content);
171        }
172
173        return new Response($content, Response::HTTP_OK, [
174            'Content-Type' => 'application/xml',
175        ]);
176    }
177
178    /**
179     * @param Request $request
180     *
181     * @return Response
182     */
183    public function getFileAction(Request $request): Response
184    {
185        $file = $request->get('file', '');
186
187        if (!preg_match('/^(\d+)-([imnrs])-(\d+)$/', $file, $match)) {
188            throw new NotFoundHttpException('Bad sitemap file');
189        }
190
191        $timestamp = (int) $this->getPreference('sitemap-' . $file . '.timestamp');
192
193        if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) {
194            $content = $this->getPreference('sitemap-' . $file . '.xml');
195        } else {
196            $tree = Tree::findById((int) $match[1]);
197
198            if ($tree === null) {
199                throw new NotFoundHttpException('No such tree');
200            }
201
202            $records = $this->sitemapRecords($tree, $match[2], self::RECORDS_PER_VOLUME, self::RECORDS_PER_VOLUME * $match[3]);
203
204            $content = view('modules/sitemap/sitemap-file.xml', ['records' => $records]);
205
206            $this->setPreference('sitemap.xml', $content);
207        }
208
209        return new Response($content, Response::HTTP_OK, [
210            'Content-Type' => 'application/xml',
211        ]);
212    }
213
214    /**
215     * @param Tree   $tree
216     * @param string $type
217     * @param int    $limit
218     * @param int    $offset
219     *
220     * @return array
221     */
222    private function sitemapRecords(Tree $tree, string $type, int $limit, int $offset): array
223    {
224        switch ($type) {
225            case 'i':
226                $records = $this->sitemapIndividuals($tree, $limit, $offset);
227                break;
228
229            case 'm':
230                $records = $this->sitemapMedia($tree, $limit, $offset);
231                break;
232
233            case 'n':
234                $records = $this->sitemapNotes($tree, $limit, $offset);
235                break;
236
237            case 'r':
238                $records = $this->sitemapRepositories($tree, $limit, $offset);
239                break;
240
241            case 's':
242                $records = $this->sitemapSources($tree, $limit, $offset);
243                break;
244
245            default:
246                throw new NotFoundHttpException('Invalid record type: ' . $type);
247        }
248
249        // Skip records that no longer exist.
250        $records = array_filter($records);
251
252        // Skip private records.
253        $records = array_filter($records, function (GedcomRecord $record): bool {
254            return $record->canShow();
255        });
256
257        return $records;
258    }
259
260    /**
261     * @param Tree $tree
262     * @param int  $limit
263     * @param int  $offset
264     *
265     * @return array
266     */
267    private function sitemapIndividuals(Tree $tree, int $limit, int $offset): array
268    {
269        $rows = DB::table('individuals')
270            ->where('i_file', '=', $tree->id())
271            ->orderBy('i_id')
272            ->skip($offset)
273            ->take($limit)
274            ->get();
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 = DB::table('media')
295            ->where('m_file', '=', $tree->id())
296            ->orderBy('m_id')
297            ->skip($offset)
298            ->take($limit)
299            ->get();
300
301        $records = [];
302
303        foreach ($rows as $row) {
304            $records[] = Media::getInstance($row->xref, $tree, $row->gedcom);
305        }
306
307        return $records;
308    }
309
310    /**
311     * @param Tree $tree
312     * @param int  $limit
313     * @param int  $offset
314     *
315     * @return array
316     */
317    private function sitemapNotes(Tree $tree, int $limit, int $offset): array
318    {
319        $rows = DB::table('other')
320            ->where('o_file', '=', $tree->id())
321            ->where('o_type', '=', 'NOTE')
322            ->orderBy('o_id')
323            ->skip($offset)
324            ->take($limit)
325            ->get();
326
327        $records = [];
328
329        foreach ($rows as $row) {
330            $records[] = Note::getInstance($row->xref, $tree, $row->gedcom);
331        }
332
333        return $records;
334    }
335
336    /**
337     * @param Tree $tree
338     * @param int  $limit
339     * @param int  $offset
340     *
341     * @return array
342     */
343    private function sitemapRepositories(Tree $tree, int $limit, int $offset): array
344    {
345        $rows = DB::table('other')
346            ->where('o_file', '=', $tree->id())
347            ->where('o_type', '=', 'REPO')
348            ->orderBy('o_id')
349            ->skip($offset)
350            ->take($limit)
351            ->get();
352
353        $records = [];
354
355        foreach ($rows as $row) {
356            $records[] = Repository::getInstance($row->xref, $tree, $row->gedcom);
357        }
358
359        return $records;
360    }
361
362    /**
363     * @param Tree $tree
364     * @param int  $limit
365     * @param int  $offset
366     *
367     * @return array
368     */
369    private function sitemapSources(Tree $tree, int $limit, int $offset): array
370    {
371        $rows = DB::table('sources')
372            ->where('s_file', '=', $tree->id())
373            ->orderBy('s_id')
374            ->skip($offset)
375            ->take($limit)
376            ->get();
377
378        $records = [];
379
380        foreach ($rows as $row) {
381            $records[] = Source::getInstance($row->xref, $tree, $row->gedcom);
382        }
383
384        return $records;
385    }
386}
387