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