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