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