1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2018 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 Fisharebest\Webtrees\Database; 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 Symfony\Component\HttpFoundation\RedirectResponse; 32use Symfony\Component\HttpFoundation\Request; 33use Symfony\Component\HttpFoundation\Response; 34use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 35 36/** 37 * Class SiteMapModule 38 */ 39class SiteMapModule extends AbstractModule implements ModuleConfigInterface 40{ 41 const RECORDS_PER_VOLUME = 500; // Keep sitemap files small, for memory, CPU and max_allowed_packet limits. 42 const CACHE_LIFE = 1209600; // Two weeks 43 44 /** 45 * How should this module be labelled on tabs, menus, etc.? 46 * 47 * @return string 48 */ 49 public function getTitle(): string 50 { 51 /* I18N: Name of a module - see http://en.wikipedia.org/wiki/Sitemaps */ 52 return I18N::translate('Sitemaps'); 53 } 54 55 /** 56 * A sentence describing what this module does. 57 * 58 * @return string 59 */ 60 public function getDescription(): string 61 { 62 /* I18N: Description of the “Sitemaps” module */ 63 return I18N::translate('Generate sitemap files for search engines.'); 64 } 65 66 /** 67 * The URL to a page where the user can modify the configuration of this module. 68 * 69 * @return string 70 */ 71 public function getConfigLink(): string 72 { 73 return route('module', [ 74 'module' => $this->getName(), 75 'action' => 'Admin', 76 ]); 77 } 78 79 /** 80 * @param Request $request 81 * 82 * @return Response 83 */ 84 public function getAdminAction(Request $request): 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::getAll(), 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::getAll() as $tree) { 115 $include_in_sitemap = (bool) $request->get('sitemap' . $tree->getTreeId()); 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 * @param Request $request 126 * 127 * @return Response 128 */ 129 public function getIndexAction(Request $request): Response 130 { 131 $timestamp = (int) $this->getPreference('sitemap.timestamp'); 132 133 if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) { 134 $content = $this->getPreference('sitemap.xml'); 135 } else { 136 $count_individuals = Database::prepare( 137 "SELECT i_file, COUNT(*) FROM `##individuals` GROUP BY i_file" 138 )->execute()->fetchAssoc(); 139 140 $count_media = Database::prepare( 141 "SELECT m_file, COUNT(*) FROM `##media` GROUP BY m_file" 142 )->execute()->fetchAssoc(); 143 144 $count_notes = Database::prepare( 145 "SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='NOTE' GROUP BY o_file" 146 )->execute()->fetchAssoc(); 147 148 $count_repositories = Database::prepare( 149 "SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='REPO' GROUP BY o_file" 150 )->execute()->fetchAssoc(); 151 152 $count_sources = Database::prepare( 153 "SELECT s_file, COUNT(*) FROM `##sources` GROUP BY s_file" 154 )->execute()->fetchAssoc(); 155 156 $content = view('modules/sitemap/sitemap-index.xml', [ 157 'all_trees' => Tree::getAll(), 158 'count_individuals' => $count_individuals, 159 'count_media' => $count_media, 160 'count_notes' => $count_notes, 161 'count_repositories' => $count_repositories, 162 'count_sources' => $count_sources, 163 'last_mod' => date('Y-m-d'), 164 'records_per_volume' => self::RECORDS_PER_VOLUME, 165 ]); 166 167 $this->setPreference('sitemap.xml', $content); 168 } 169 170 return new Response($content, Response::HTTP_OK, [ 171 'Content-Type' => 'application/xml', 172 ]); 173 } 174 175 /** 176 * @param Request $request 177 * 178 * @return Response 179 */ 180 public function getFileAction(Request $request): Response 181 { 182 $file = $request->get('file', ''); 183 184 if (!preg_match('/^(\d+)-([imnrs])-(\d+)$/', $file, $match)) { 185 throw new NotFoundHttpException('Bad sitemap file'); 186 } 187 188 $timestamp = (int) $this->getPreference('sitemap-' . $file . '.timestamp'); 189 190 if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) { 191 $content = $this->getPreference('sitemap-' . $file . '.xml'); 192 } else { 193 $tree = Tree::findById((int) $match[1]); 194 195 if ($tree === null) { 196 throw new NotFoundHttpException('No such tree'); 197 } 198 199 $records = $this->sitemapRecords($tree, $match[2], self::RECORDS_PER_VOLUME, self::RECORDS_PER_VOLUME * $match[3]); 200 201 $content = view('modules/sitemap/sitemap-file.xml', ['records' => $records]); 202 203 $this->setPreference('sitemap.xml', $content); 204 } 205 206 return new Response($content, Response::HTTP_OK, [ 207 'Content-Type' => 'application/xml', 208 ]); 209 } 210 211 /** 212 * @param Tree $tree 213 * @param string $type 214 * @param int $limit 215 * @param int $offset 216 * 217 * @return array 218 */ 219 private function sitemapRecords(Tree $tree, string $type, int $limit, int $offset): array 220 { 221 switch ($type) { 222 case 'i': 223 $records = $this->sitemapIndividuals($tree, $limit, $offset); 224 break; 225 226 case 'm': 227 $records = $this->sitemapMedia($tree, $limit, $offset); 228 break; 229 230 case 'n': 231 $records = $this->sitemapNotes($tree, $limit, $offset); 232 break; 233 234 case 'r': 235 $records = $this->sitemapRepositories($tree, $limit, $offset); 236 break; 237 238 case 's': 239 $records = $this->sitemapSources($tree, $limit, $offset); 240 break; 241 242 default: 243 throw new NotFoundHttpException('Invalid record type: ' . $type); 244 } 245 246 // Skip records that no longer exist. 247 $records = array_filter($records); 248 249 // Skip private records. 250 $records = array_filter($records, function (GedcomRecord $record): bool { 251 return $record->canShow(); 252 }); 253 254 return $records; 255 } 256 257 /** 258 * @param Tree $tree 259 * @param int $limit 260 * @param int $offset 261 * 262 * @return array 263 */ 264 private function sitemapIndividuals(Tree $tree, int $limit, int $offset): array 265 { 266 $rows = Database::prepare( 267 "SELECT i_id AS xref, i_gedcom AS gedcom" . 268 " FROM `##individuals`" . 269 " WHERE i_file = :tree_id" . 270 " ORDER BY i_id" . 271 " LIMIT :limit OFFSET :offset" 272 )->execute([ 273 'tree_id' => $tree->getTreeId(), 274 'limit' => $limit, 275 'offset' => $offset, 276 ])->fetchAll(); 277 278 $records = []; 279 280 foreach ($rows as $row) { 281 $records[] = Individual::getInstance($row->xref, $tree, $row->gedcom); 282 } 283 284 return $records; 285 } 286 287 /** 288 * @param Tree $tree 289 * @param int $limit 290 * @param int $offset 291 * 292 * @return array 293 */ 294 private function sitemapMedia(Tree $tree, int $limit, int $offset): array 295 { 296 $rows = Database::prepare( 297 "SELECT m_id AS xref, m_gedcom AS gedcom" . 298 " FROM `##media`" . 299 " WHERE m_file = :tree_id" . 300 " ORDER BY m_id" . 301 " LIMIT :limit OFFSET :offset" 302 )->execute([ 303 'tree_id' => $tree->getTreeId(), 304 'limit' => $limit, 305 'offset' => $offset, 306 ])->fetchAll(); 307 308 $records = []; 309 310 foreach ($rows as $row) { 311 $records[] = Media::getInstance($row->xref, $tree, $row->gedcom); 312 } 313 314 return $records; 315 } 316 317 /** 318 * @param Tree $tree 319 * @param int $limit 320 * @param int $offset 321 * 322 * @return array 323 */ 324 private function sitemapNotes(Tree $tree, int $limit, int $offset): array 325 { 326 $rows = Database::prepare( 327 "SELECT o_id AS xref, o_gedcom AS gedcom" . 328 " FROM `##other`" . 329 " WHERE o_file = :tree_id AND o_type = 'NOTE'" . 330 " ORDER BY o_id" . 331 " LIMIT :limit OFFSET :offset" 332 )->execute([ 333 'tree_id' => $tree->getTreeId(), 334 'limit' => $limit, 335 'offset' => $offset, 336 ])->fetchAll(); 337 338 $records = []; 339 340 foreach ($rows as $row) { 341 $records[] = Note::getInstance($row->xref, $tree, $row->gedcom); 342 } 343 344 return $records; 345 } 346 347 /** 348 * @param Tree $tree 349 * @param int $limit 350 * @param int $offset 351 * 352 * @return array 353 */ 354 private function sitemapRepositories(Tree $tree, int $limit, int $offset): array 355 { 356 $rows = Database::prepare( 357 "SELECT o_id AS xref, o_gedcom AS gedcom" . 358 " FROM `##other`" . 359 " WHERE o_file = :tree_id AND o_type = 'REPO'" . 360 " ORDER BY o_id" . 361 " LIMIT :limit OFFSET :offset" 362 )->execute([ 363 'tree_id' => $tree->getTreeId(), 364 'limit' => $limit, 365 'offset' => $offset, 366 ])->fetchAll(); 367 368 $records = []; 369 370 foreach ($rows as $row) { 371 $records[] = Repository::getInstance($row->xref, $tree, $row->gedcom); 372 } 373 374 return $records; 375 } 376 377 /** 378 * @param Tree $tree 379 * @param int $limit 380 * @param int $offset 381 * 382 * @return array 383 */ 384 private function sitemapSources(Tree $tree, int $limit, int $offset): array 385 { 386 $rows = Database::prepare( 387 "SELECT s_id AS xref, s_gedcom AS gedcom" . 388 " FROM `##sources`" . 389 " WHERE s_file = :tree_id" . 390 " ORDER BY s_id" . 391 " LIMIT :limit OFFSET :offset" 392 )->execute([ 393 'tree_id' => $tree->getTreeId(), 394 'limit' => $limit, 395 'offset' => $offset, 396 ])->fetchAll(); 397 398 $records = []; 399 400 foreach ($rows as $row) { 401 $records[] = Source::getInstance($row->xref, $tree, $row->gedcom); 402 } 403 404 return $records; 405 } 406} 407