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