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