xref: /webtrees/app/Module/SiteMapModule.php (revision 3eae54c8a20b98985facfcff8d58c982338bbf85)
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\Database\Query\Expression;
34use Illuminate\Support\Collection;
35use Psr\Http\Message\ResponseInterface;
36use Psr\Http\Message\ServerRequestInterface;
37use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
38
39/**
40 * Class SiteMapModule
41 */
42class SiteMapModule extends AbstractModule implements ModuleConfigInterface
43{
44    use ModuleConfigTrait;
45
46    private const RECORDS_PER_VOLUME = 500; // Keep sitemap files small, for memory, CPU and max_allowed_packet limits.
47    private const CACHE_LIFE         = 1209600; // Two weeks
48
49    /**
50     * A sentence describing what this module does.
51     *
52     * @return string
53     */
54    public function description(): string
55    {
56        /* I18N: Description of the “Sitemaps” module */
57        return I18N::translate('Generate sitemap files for search engines.');
58    }
59
60    /**
61     * Should this module be enabled when it is first installed?
62     *
63     * @return bool
64     */
65    public function isEnabledByDefault(): bool
66    {
67        return false;
68    }
69
70    /**
71     * @return ResponseInterface
72     */
73    public function getAdminAction(): ResponseInterface
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     * How should this module be identified in the control panel, etc.?
98     *
99     * @return string
100     */
101    public function title(): string
102    {
103        /* I18N: Name of a module - see http://en.wikipedia.org/wiki/Sitemaps */
104        return I18N::translate('Sitemaps');
105    }
106
107    /**
108     * @param ServerRequestInterface $request
109     *
110     * @return ResponseInterface
111     */
112    public function postAdminAction(ServerRequestInterface $request): ResponseInterface
113    {
114        $params = $request->getParsedBody();
115
116        foreach (Tree::all() as $tree) {
117            $include_in_sitemap = (bool) ($params['sitemap' . $tree->id()] ?? false);
118            $tree->setPreference('include_in_sitemap', (string) $include_in_sitemap);
119        }
120
121        FlashMessages::addMessage(I18N::translate('The preferences for the module “%s” have been updated.', $this->title()), 'success');
122
123        return redirect($this->getConfigLink());
124    }
125
126    /**
127     * @return ResponseInterface
128     */
129    public function getIndexAction(): ResponseInterface
130    {
131        $timestamp = (int) $this->getPreference('sitemap.timestamp');
132
133        if ($timestamp > Carbon::now()->subSeconds(self::CACHE_LIFE)->unix()) {
134            $content = $this->getPreference('sitemap.xml');
135        } else {
136            $count_individuals = DB::table('individuals')
137                ->groupBy('i_file')
138                ->select([new Expression('COUNT(*) AS total'), 'i_file'])
139                ->pluck('total', 'i_file');
140
141            $count_media = DB::table('media')
142                ->groupBy('m_file')
143                ->select([new Expression('COUNT(*) AS total'), 'm_file'])
144                ->pluck('total', 'm_file');
145
146            $count_notes = DB::table('other')
147                ->where('o_type', '=', 'NOTE')
148                ->groupBy('o_file')
149                ->select([new Expression('COUNT(*) AS total'), 'o_file'])
150                ->pluck('total', 'o_file');
151
152            $count_repositories = DB::table('other')
153                ->where('o_type', '=', 'REPO')
154                ->groupBy('o_file')
155                ->select([new Expression('COUNT(*) AS total'), 'o_file'])
156                ->pluck('total', 'o_file');
157
158            $count_sources = DB::table('sources')
159                ->groupBy('s_file')
160                ->select([new Expression('COUNT(*) AS total'), 's_file'])
161                ->pluck('total', 's_file');
162
163            $content = view('modules/sitemap/sitemap-index.xml', [
164                'all_trees'          => Tree::all(),
165                'count_individuals'  => $count_individuals,
166                'count_media'        => $count_media,
167                'count_notes'        => $count_notes,
168                'count_repositories' => $count_repositories,
169                'count_sources'      => $count_sources,
170                'last_mod'           => date('Y-m-d'),
171                'records_per_volume' => self::RECORDS_PER_VOLUME,
172            ]);
173
174            $this->setPreference('sitemap.xml', $content);
175        }
176
177        return response($content, StatusCodeInterface::STATUS_OK, [
178            'Content-Type' => 'application/xml',
179        ]);
180    }
181
182    /**
183     * @param ServerRequestInterface $request
184     *
185     * @return ResponseInterface
186     */
187    public function getFileAction(ServerRequestInterface $request): ResponseInterface
188    {
189        $file = $request->getQueryParams()['file'];
190
191        if (!preg_match('/^(\d+)-([imnrs])-(\d+)$/', $file, $match)) {
192            throw new NotFoundHttpException('Bad sitemap file');
193        }
194
195        $timestamp   = (int) $this->getPreference('sitemap-' . $file . '.timestamp');
196        $expiry_time = Carbon::now()->subSeconds(self::CACHE_LIFE)->unix();
197
198        if ($timestamp > $expiry_time) {
199            $content = $this->getPreference('sitemap-' . $file . '.xml');
200        } else {
201            $tree = Tree::findById((int) $match[1]);
202
203            if ($tree === null) {
204                throw new NotFoundHttpException('No such tree');
205            }
206
207            $records = $this->sitemapRecords($tree, $match[2], self::RECORDS_PER_VOLUME, self::RECORDS_PER_VOLUME * $match[3]);
208
209            $content = view('modules/sitemap/sitemap-file.xml', ['records' => $records]);
210
211            $this->setPreference('sitemap.xml', $content);
212        }
213
214        return response($content, StatusCodeInterface::STATUS_OK, [
215            'Content-Type' => 'application/xml',
216        ]);
217    }
218
219    /**
220     * @param Tree   $tree
221     * @param string $type
222     * @param int    $limit
223     * @param int    $offset
224     *
225     * @return Collection
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     */
267    private function sitemapIndividuals(Tree $tree, int $limit, int $offset): Collection
268    {
269        return DB::table('individuals')
270            ->where('i_file', '=', $tree->id())
271            ->orderBy('i_id')
272            ->skip($offset)
273            ->take($limit)
274            ->get()
275            ->map(Individual::rowMapper());
276    }
277
278    /**
279     * @param Tree $tree
280     * @param int  $limit
281     * @param int  $offset
282     *
283     * @return Collection
284     */
285    private function sitemapMedia(Tree $tree, int $limit, int $offset): Collection
286    {
287        return DB::table('media')
288            ->where('m_file', '=', $tree->id())
289            ->orderBy('m_id')
290            ->skip($offset)
291            ->take($limit)
292            ->get()
293            ->map(Media::rowMapper());
294    }
295
296    /**
297     * @param Tree $tree
298     * @param int  $limit
299     * @param int  $offset
300     *
301     * @return Collection
302     */
303    private function sitemapNotes(Tree $tree, int $limit, int $offset): Collection
304    {
305        return DB::table('other')
306            ->where('o_file', '=', $tree->id())
307            ->where('o_type', '=', 'NOTE')
308            ->orderBy('o_id')
309            ->skip($offset)
310            ->take($limit)
311            ->get()
312            ->map(Note::rowMapper());
313    }
314
315    /**
316     * @param Tree $tree
317     * @param int  $limit
318     * @param int  $offset
319     *
320     * @return Collection
321     */
322    private function sitemapRepositories(Tree $tree, int $limit, int $offset): Collection
323    {
324        return DB::table('other')
325            ->where('o_file', '=', $tree->id())
326            ->where('o_type', '=', 'REPO')
327            ->orderBy('o_id')
328            ->skip($offset)
329            ->take($limit)
330            ->get()
331            ->map(Repository::rowMapper());
332    }
333
334    /**
335     * @param Tree $tree
336     * @param int  $limit
337     * @param int  $offset
338     *
339     * @return Collection
340     */
341    private function sitemapSources(Tree $tree, int $limit, int $offset): Collection
342    {
343        return DB::table('sources')
344            ->where('s_file', '=', $tree->id())
345            ->orderBy('s_id')
346            ->skip($offset)
347            ->take($limit)
348            ->get()
349            ->map(Source::rowMapper());
350    }
351}
352