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