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