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 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 * @return Response 81 */ 82 public function getAdminAction(): 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->id()); 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 * @return Response 124 */ 125 public function getIndexAction(): Response 126 { 127 $timestamp = (int) $this->getPreference('sitemap.timestamp'); 128 129 if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) { 130 $content = $this->getPreference('sitemap.xml'); 131 } else { 132 $count_individuals = Database::prepare( 133 "SELECT i_file, COUNT(*) FROM `##individuals` GROUP BY i_file" 134 )->execute()->fetchAssoc(); 135 136 $count_media = Database::prepare( 137 "SELECT m_file, COUNT(*) FROM `##media` GROUP BY m_file" 138 )->execute()->fetchAssoc(); 139 140 $count_notes = Database::prepare( 141 "SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='NOTE' GROUP BY o_file" 142 )->execute()->fetchAssoc(); 143 144 $count_repositories = Database::prepare( 145 "SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='REPO' GROUP BY o_file" 146 )->execute()->fetchAssoc(); 147 148 $count_sources = Database::prepare( 149 "SELECT s_file, COUNT(*) FROM `##sources` GROUP BY s_file" 150 )->execute()->fetchAssoc(); 151 152 $content = view('modules/sitemap/sitemap-index.xml', [ 153 'all_trees' => Tree::getAll(), 154 'count_individuals' => $count_individuals, 155 'count_media' => $count_media, 156 'count_notes' => $count_notes, 157 'count_repositories' => $count_repositories, 158 'count_sources' => $count_sources, 159 'last_mod' => date('Y-m-d'), 160 'records_per_volume' => self::RECORDS_PER_VOLUME, 161 ]); 162 163 $this->setPreference('sitemap.xml', $content); 164 } 165 166 return new Response($content, Response::HTTP_OK, [ 167 'Content-Type' => 'application/xml', 168 ]); 169 } 170 171 /** 172 * @param Request $request 173 * 174 * @return Response 175 */ 176 public function getFileAction(Request $request): Response 177 { 178 $file = $request->get('file', ''); 179 180 if (!preg_match('/^(\d+)-([imnrs])-(\d+)$/', $file, $match)) { 181 throw new NotFoundHttpException('Bad sitemap file'); 182 } 183 184 $timestamp = (int) $this->getPreference('sitemap-' . $file . '.timestamp'); 185 186 if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) { 187 $content = $this->getPreference('sitemap-' . $file . '.xml'); 188 } else { 189 $tree = Tree::findById((int) $match[1]); 190 191 if ($tree === null) { 192 throw new NotFoundHttpException('No such tree'); 193 } 194 195 $records = $this->sitemapRecords($tree, $match[2], self::RECORDS_PER_VOLUME, self::RECORDS_PER_VOLUME * $match[3]); 196 197 $content = view('modules/sitemap/sitemap-file.xml', ['records' => $records]); 198 199 $this->setPreference('sitemap.xml', $content); 200 } 201 202 return new Response($content, Response::HTTP_OK, [ 203 'Content-Type' => 'application/xml', 204 ]); 205 } 206 207 /** 208 * @param Tree $tree 209 * @param string $type 210 * @param int $limit 211 * @param int $offset 212 * 213 * @return array 214 */ 215 private function sitemapRecords(Tree $tree, string $type, int $limit, int $offset): array 216 { 217 switch ($type) { 218 case 'i': 219 $records = $this->sitemapIndividuals($tree, $limit, $offset); 220 break; 221 222 case 'm': 223 $records = $this->sitemapMedia($tree, $limit, $offset); 224 break; 225 226 case 'n': 227 $records = $this->sitemapNotes($tree, $limit, $offset); 228 break; 229 230 case 'r': 231 $records = $this->sitemapRepositories($tree, $limit, $offset); 232 break; 233 234 case 's': 235 $records = $this->sitemapSources($tree, $limit, $offset); 236 break; 237 238 default: 239 throw new NotFoundHttpException('Invalid record type: ' . $type); 240 } 241 242 // Skip records that no longer exist. 243 $records = array_filter($records); 244 245 // Skip private records. 246 $records = array_filter($records, function (GedcomRecord $record): bool { 247 return $record->canShow(); 248 }); 249 250 return $records; 251 } 252 253 /** 254 * @param Tree $tree 255 * @param int $limit 256 * @param int $offset 257 * 258 * @return array 259 */ 260 private function sitemapIndividuals(Tree $tree, int $limit, int $offset): array 261 { 262 $rows = Database::prepare( 263 "SELECT i_id AS xref, i_gedcom AS gedcom" . 264 " FROM `##individuals`" . 265 " WHERE i_file = :tree_id" . 266 " ORDER BY i_id" . 267 " LIMIT :limit OFFSET :offset" 268 )->execute([ 269 'tree_id' => $tree->id(), 270 'limit' => $limit, 271 'offset' => $offset, 272 ])->fetchAll(); 273 274 $records = []; 275 276 foreach ($rows as $row) { 277 $records[] = Individual::getInstance($row->xref, $tree, $row->gedcom); 278 } 279 280 return $records; 281 } 282 283 /** 284 * @param Tree $tree 285 * @param int $limit 286 * @param int $offset 287 * 288 * @return array 289 */ 290 private function sitemapMedia(Tree $tree, int $limit, int $offset): array 291 { 292 $rows = Database::prepare( 293 "SELECT m_id AS xref, m_gedcom AS gedcom" . 294 " FROM `##media`" . 295 " WHERE m_file = :tree_id" . 296 " ORDER BY m_id" . 297 " LIMIT :limit OFFSET :offset" 298 )->execute([ 299 'tree_id' => $tree->id(), 300 'limit' => $limit, 301 'offset' => $offset, 302 ])->fetchAll(); 303 304 $records = []; 305 306 foreach ($rows as $row) { 307 $records[] = Media::getInstance($row->xref, $tree, $row->gedcom); 308 } 309 310 return $records; 311 } 312 313 /** 314 * @param Tree $tree 315 * @param int $limit 316 * @param int $offset 317 * 318 * @return array 319 */ 320 private function sitemapNotes(Tree $tree, int $limit, int $offset): array 321 { 322 $rows = Database::prepare( 323 "SELECT o_id AS xref, o_gedcom AS gedcom" . 324 " FROM `##other`" . 325 " WHERE o_file = :tree_id AND o_type = 'NOTE'" . 326 " ORDER BY o_id" . 327 " LIMIT :limit OFFSET :offset" 328 )->execute([ 329 'tree_id' => $tree->id(), 330 'limit' => $limit, 331 'offset' => $offset, 332 ])->fetchAll(); 333 334 $records = []; 335 336 foreach ($rows as $row) { 337 $records[] = Note::getInstance($row->xref, $tree, $row->gedcom); 338 } 339 340 return $records; 341 } 342 343 /** 344 * @param Tree $tree 345 * @param int $limit 346 * @param int $offset 347 * 348 * @return array 349 */ 350 private function sitemapRepositories(Tree $tree, int $limit, int $offset): array 351 { 352 $rows = Database::prepare( 353 "SELECT o_id AS xref, o_gedcom AS gedcom" . 354 " FROM `##other`" . 355 " WHERE o_file = :tree_id AND o_type = 'REPO'" . 356 " ORDER BY o_id" . 357 " LIMIT :limit OFFSET :offset" 358 )->execute([ 359 'tree_id' => $tree->id(), 360 'limit' => $limit, 361 'offset' => $offset, 362 ])->fetchAll(); 363 364 $records = []; 365 366 foreach ($rows as $row) { 367 $records[] = Repository::getInstance($row->xref, $tree, $row->gedcom); 368 } 369 370 return $records; 371 } 372 373 /** 374 * @param Tree $tree 375 * @param int $limit 376 * @param int $offset 377 * 378 * @return array 379 */ 380 private function sitemapSources(Tree $tree, int $limit, int $offset): array 381 { 382 $rows = Database::prepare( 383 "SELECT s_id AS xref, s_gedcom AS gedcom" . 384 " FROM `##sources`" . 385 " WHERE s_file = :tree_id" . 386 " ORDER BY s_id" . 387 " LIMIT :limit OFFSET :offset" 388 )->execute([ 389 'tree_id' => $tree->id(), 390 'limit' => $limit, 391 'offset' => $offset, 392 ])->fetchAll(); 393 394 $records = []; 395 396 foreach ($rows as $row) { 397 $records[] = Source::getInstance($row->xref, $tree, $row->gedcom); 398 } 399 400 return $records; 401 } 402} 403