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