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(): string 48 { 49 /* I18N: Name of a module - see http://en.wikipedia.org/wiki/Sitemaps */ 50 return I18N::translate('Sitemaps'); 51 } 52 53 /** 54 * A sentence describing what this module does. 55 * 56 * @return string 57 */ 58 public function getDescription(): string 59 { 60 /* I18N: Description of the “Sitemaps” module */ 61 return 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(): string 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, self::RECORDS_PER_VOLUME * $match[3]); 198 199 $content = view('modules/sitemap/sitemap-file.xml', ['records' => $records]); 200 201 $this->setPreference('sitemap.xml', $content); 202 } 203 204 return new Response($content, Response::HTTP_OK, [ 205 'Content-Type' => 'application/xml', 206 ]); 207 } 208 209 /** 210 * @param Tree $tree 211 * @param string $type 212 * @param int $limit 213 * @param int $offset 214 * 215 * @return array 216 */ 217 private function sitemapRecords(Tree $tree, string $type, int $limit, int $offset): array 218 { 219 switch ($type) { 220 case 'i': 221 $records = $this->sitemapIndividuals($tree, $limit, $offset); 222 break; 223 224 case 'm': 225 $records = $this->sitemapMedia($tree, $limit, $offset); 226 break; 227 228 case 'n': 229 $records = $this->sitemapNotes($tree, $limit, $offset); 230 break; 231 232 case 'r': 233 $records = $this->sitemapRepositories($tree, $limit, $offset); 234 break; 235 236 case 's': 237 $records = $this->sitemapSources($tree, $limit, $offset); 238 break; 239 240 default: 241 throw new NotFoundHttpException('Invalid record type: ' . $type); 242 } 243 244 // Skip records that no longer exist. 245 $records = array_filter($records); 246 247 // Skip private records. 248 $records = array_filter($records, function (GedcomRecord $record): bool { 249 return $record->canShow(); 250 }); 251 252 return $records; 253 } 254 255 /** 256 * @param Tree $tree 257 * @param int $limit 258 * @param int $offset 259 * 260 * @return array 261 */ 262 private function sitemapIndividuals(Tree $tree, int $limit, int $offset): array 263 { 264 $rows = Database::prepare( 265 "SELECT i_id AS xref, i_gedcom AS gedcom" . 266 " FROM `##individuals`" . 267 " WHERE i_file = :tree_id" . 268 " ORDER BY i_id" . 269 " LIMIT :limit OFFSET :offset" 270 )->execute([ 271 'tree_id' => $tree->getTreeId(), 272 'limit' => $limit, 273 'offset' => $offset, 274 ])->fetchAll(); 275 276 $records = []; 277 278 foreach ($rows as $row) { 279 $records[] = Individual::getInstance($row->xref, $tree, $row->gedcom); 280 } 281 282 return $records; 283 } 284 285 /** 286 * @param Tree $tree 287 * @param int $limit 288 * @param int $offset 289 * 290 * @return array 291 */ 292 private function sitemapMedia(Tree $tree, int $limit, int $offset): array 293 { 294 $rows = Database::prepare( 295 "SELECT m_id AS xref, m_gedcom AS gedcom" . 296 " FROM `##media`" . 297 " WHERE m_file = :tree_id" . 298 " ORDER BY m_id" . 299 " LIMIT :limit OFFSET :offset" 300 )->execute([ 301 'tree_id' => $tree->getTreeId(), 302 'limit' => $limit, 303 'offset' => $offset, 304 ])->fetchAll(); 305 306 $records = []; 307 308 foreach ($rows as $row) { 309 $records[] = Media::getInstance($row->xref, $tree, $row->gedcom); 310 } 311 312 return $records; 313 } 314 315 /** 316 * @param Tree $tree 317 * @param int $limit 318 * @param int $offset 319 * 320 * @return array 321 */ 322 private function sitemapNotes(Tree $tree, int $limit, int $offset): array 323 { 324 $rows = Database::prepare( 325 "SELECT o_id AS xref, o_gedcom AS gedcom" . 326 " FROM `##other`" . 327 " WHERE o_file = :tree_id AND o_type = 'NOTE'" . 328 " ORDER BY o_id" . 329 " LIMIT :limit OFFSET :offset" 330 )->execute([ 331 'tree_id' => $tree->getTreeId(), 332 'limit' => $limit, 333 'offset' => $offset, 334 ])->fetchAll(); 335 336 $records = []; 337 338 foreach ($rows as $row) { 339 $records[] = Note::getInstance($row->xref, $tree, $row->gedcom); 340 } 341 342 return $records; 343 } 344 345 /** 346 * @param Tree $tree 347 * @param int $limit 348 * @param int $offset 349 * 350 * @return array 351 */ 352 private function sitemapRepositories(Tree $tree, int $limit, int $offset): array 353 { 354 $rows = Database::prepare( 355 "SELECT o_id AS xref, o_gedcom AS gedcom" . 356 " FROM `##other`" . 357 " WHERE o_file = :tree_id AND o_type = 'REPO'" . 358 " ORDER BY o_id" . 359 " LIMIT :limit OFFSET :offset" 360 )->execute([ 361 'tree_id' => $tree->getTreeId(), 362 'limit' => $limit, 363 'offset' => $offset, 364 ])->fetchAll(); 365 366 $records = []; 367 368 foreach ($rows as $row) { 369 $records[] = Repository::getInstance($row->xref, $tree, $row->gedcom); 370 } 371 372 return $records; 373 } 374 375 /** 376 * @param Tree $tree 377 * @param int $limit 378 * @param int $offset 379 * 380 * @return array 381 */ 382 private function sitemapSources(Tree $tree, int $limit, int $offset): array 383 { 384 $rows = Database::prepare( 385 "SELECT s_id AS xref, s_gedcom AS gedcom" . 386 " FROM `##sources`" . 387 " WHERE s_file = :tree_id" . 388 " ORDER BY s_id" . 389 " LIMIT :limit OFFSET :offset" 390 )->execute([ 391 'tree_id' => $tree->getTreeId(), 392 'limit' => $limit, 393 'offset' => $offset, 394 ])->fetchAll(); 395 396 $records = []; 397 398 foreach ($rows as $row) { 399 $records[] = Source::getInstance($row->xref, $tree, $row->gedcom); 400 } 401 402 return $records; 403 } 404} 405