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