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