diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/Module/SiteMapModule.php | 570 |
1 files changed, 303 insertions, 267 deletions
diff --git a/app/Module/SiteMapModule.php b/app/Module/SiteMapModule.php index 1e351df412..ca03b23444 100644 --- a/app/Module/SiteMapModule.php +++ b/app/Module/SiteMapModule.php @@ -15,11 +15,9 @@ */ namespace Fisharebest\Webtrees\Module; -use Fisharebest\Webtrees\Auth; -use Fisharebest\Webtrees\Bootstrap4; -use Fisharebest\Webtrees\Controller\PageController; use Fisharebest\Webtrees\Database; -use Fisharebest\Webtrees\Filter; +use Fisharebest\Webtrees\FlashMessages; +use Fisharebest\Webtrees\GedcomRecord; use Fisharebest\Webtrees\Html; use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\Individual; @@ -28,6 +26,10 @@ use Fisharebest\Webtrees\Note; use Fisharebest\Webtrees\Repository; use Fisharebest\Webtrees\Source; use Fisharebest\Webtrees\Tree; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Class SiteMapModule @@ -36,314 +38,348 @@ class SiteMapModule extends AbstractModule implements ModuleConfigInterface { const RECORDS_PER_VOLUME = 500; // Keep sitemap files small, for memory, CPU and max_allowed_packet limits. const CACHE_LIFE = 1209600; // Two weeks - /** {@inheritdoc} */ + /** + * How should this module be labelled on tabs, menus, etc.? + * + * @return string + */ public function getTitle() { - return /* I18N: Name of a module - see http://en.wikipedia.org/wiki/Sitemaps */ I18N::translate('Sitemaps'); + return /* I18N: Name of a module - see http://en.wikipedia.org/wiki/Sitemaps */ + I18N::translate('Sitemaps'); } - /** {@inheritdoc} */ + /** + * A sentence describing what this module does. + * + * @return string + */ public function getDescription() { - return /* I18N: Description of the “Sitemaps” module */ I18N::translate('Generate sitemap files for search engines.'); + return /* I18N: Description of the “Sitemaps” module */ + I18N::translate('Generate sitemap files for search engines.'); } /** - * This is a general purpose hook, allowing modules to respond to routes - * of the form module.php?mod=FOO&mod_action=BAR + * The URL to a page where the user can modify the configuration of this module. * - * @param string $mod_action + * @return string */ - public function modAction($mod_action) { - switch ($mod_action) { - case 'admin': - $this->admin(); - break; - case 'generate': - $this->generate(Filter::get('file')); - break; - default: - http_response_code(404); - } + public function getConfigLink() { + return route('module', ['module' => 'sitemap', 'action' => 'Admin']); } /** - * Generate an XML file. + * @param Request $request * - * @param string $file + * @return Response */ - private function generate($file) { - if ($file == 'sitemap.xml') { - $this->generateIndex(); - } elseif (preg_match('/^sitemap-(\d+)-([isrmn])-(\d+).xml$/', $file, $match)) { - $this->generateFile($match[1], $match[2], $match[3]); - } else { - http_response_code(404); + public function getAdminAction(Request $request): Response { + $this->layout = 'layouts/administration'; + + $sitemap_url = route('module', ['module' => 'sitemap', 'action' => 'Index']); + + // This list comes from http://en.wikipedia.org/wiki/Sitemaps + $submit_urls = [ + 'Bing/Yahoo' => Html::url('https://www.bing.com/webmaster/ping.aspx', ['siteMap' => $sitemap_url]), + 'Google' => Html::url('https://www.google.com/webmasters/tools/ping', ['sitemap' => $sitemap_url]), + ]; + + return $this->viewResponse('modules/sitemap/admin', [ + 'all_trees' => Tree::getAll(), + 'sitemap_url' => $sitemap_url, + 'submit_urls' => $submit_urls, + 'title' => $this->getTitle(), + ]); + } + + /** + * @param Request $request + * + * @return RedirectResponse + */ + public function postAdminAction(Request $request): RedirectResponse { + foreach (Tree::getAll() as $tree) { + $include_in_sitemap = (bool) $request->get('sitemap' . $tree->getTreeId()); + $tree->setPreference('include_in_sitemap', (string) $include_in_sitemap); } + + FlashMessages::addMessage(I18N::translate('The preferences for the module “%s” have been updated.', $this->getName()), 'success'); + + return new RedirectResponse($this->getConfigLink()); } /** - * The index file contains references to all the other files. - * These files are the same for visitors/users/admins. + * @param Request $request + * + * @return Response */ - private function generateIndex() { - // Check the cache + public function getIndexAction(Request $request): Response { $timestamp = (int) $this->getPreference('sitemap.timestamp'); + if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) { - $data = $this->getPreference('sitemap.xml'); + $content = $this->getPreference('sitemap.xml'); } else { - $data = ''; - $lastmod = '<lastmod>' . date('Y-m-d') . '</lastmod>'; - foreach (Tree::getAll() as $tree) { - if ($tree->getPreference('include_in_sitemap')) { - $n = Database::prepare( - "SELECT COUNT(*) FROM `##individuals` WHERE i_file = :tree_id" - )->execute(['tree_id' => $tree->getTreeId()])->fetchOne(); - for ($i = 0; $i <= $n / self::RECORDS_PER_VOLUME; ++$i) { - $data .= '<sitemap><loc>' . WT_BASE_URL . 'module.php?mod=' . $this->getName() . '&mod_action=generate&file=sitemap-' . $tree->getTreeId() . '-i-' . $i . '.xml</loc>' . $lastmod . '</sitemap>' . PHP_EOL; - } - $n = Database::prepare( - "SELECT COUNT(*) FROM `##sources` WHERE s_file = :tree_id" - )->execute(['tree_id' => $tree->getTreeId()])->fetchOne(); - if ($n) { - for ($i = 0; $i <= $n / self::RECORDS_PER_VOLUME; ++$i) { - $data .= '<sitemap><loc>' . WT_BASE_URL . 'module.php?mod=' . $this->getName() . '&mod_action=generate&file=sitemap-' . $tree->getTreeId() . '-s-' . $i . '.xml</loc>' . $lastmod . '</sitemap>' . PHP_EOL; - } - } - $n = Database::prepare( - "SELECT COUNT(*) FROM `##other` WHERE o_file = :tree_id AND o_type = 'REPO'" - )->execute(['tree_id' => $tree->getTreeId()])->fetchOne(); - if ($n) { - for ($i = 0; $i <= $n / self::RECORDS_PER_VOLUME; ++$i) { - $data .= '<sitemap><loc>' . WT_BASE_URL . 'module.php?mod=' . $this->getName() . '&mod_action=generate&file=sitemap-' . $tree->getTreeId() . '-r-' . $i . '.xml</loc>' . $lastmod . '</sitemap>' . PHP_EOL; - } - } - $n = Database::prepare( - "SELECT COUNT(*) FROM `##other` WHERE o_file = :tree_id AND o_type = 'NOTE'" - )->execute(['tree_id' => $tree->getTreeId()])->fetchOne(); - if ($n) { - for ($i = 0; $i <= $n / self::RECORDS_PER_VOLUME; ++$i) { - $data .= '<sitemap><loc>' . WT_BASE_URL . 'module.php?mod=' . $this->getName() . '&mod_action=generate&file=sitemap-' . $tree->getTreeId() . '-n-' . $i . '.xml</loc>' . $lastmod . '</sitemap>' . PHP_EOL; - } - } - $n = Database::prepare( - "SELECT COUNT(*) FROM `##media` WHERE m_file = :tree_id" - )->execute(['tree_id' => $tree->getTreeId()])->fetchOne(); - if ($n) { - for ($i = 0; $i <= $n / self::RECORDS_PER_VOLUME; ++$i) { - $data .= '<sitemap><loc>' . WT_BASE_URL . 'module.php?mod=' . $this->getName() . '&mod_action=generate&file=sitemap-' . $tree->getTreeId() . '-m-' . $i . '.xml</loc>' . $lastmod . '</sitemap>' . PHP_EOL; - } - } - } - } - $data = '<' . '?xml version="1.0" encoding="UTF-8" ?' . '>' . PHP_EOL . '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . PHP_EOL . $data . '</sitemapindex>' . PHP_EOL; - // Cache this data. - $this->setPreference('sitemap.xml', $data); - $this->setPreference('sitemap.timestamp', WT_TIMESTAMP); + $count_individuals = Database::prepare( + "SELECT i_file, COUNT(*) FROM `##individuals` GROUP BY i_file" + )->execute()->fetchAssoc(); + + $count_media = Database::prepare( + "SELECT m_file, COUNT(*) FROM `##media` GROUP BY m_file" + )->execute()->fetchAssoc(); + + $count_notes = Database::prepare( + "SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='NOTE' GROUP BY o_file" + )->execute()->fetchAssoc(); + + $count_repositories = Database::prepare( + "SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='REPO' GROUP BY o_file" + )->execute()->fetchAssoc(); + + $count_sources = Database::prepare( + "SELECT s_file, COUNT(*) FROM `##sources` GROUP BY s_file" + )->execute()->fetchAssoc(); + + $content = view('modules/sitemap/sitemap-index', [ + 'all_trees' => Tree::getAll(), + 'count_individuals' => $count_individuals, + 'count_media' => $count_media, + 'count_notes' => $count_notes, + 'count_repositories' => $count_repositories, + 'count_sources' => $count_sources, + 'last_mod' => date('Y-m-d'), + 'records_per_volume' => self::RECORDS_PER_VOLUME, + ]); + + $this->setPreference('sitemap.xml', $content); } - header('Content-Type: application/xml'); - header('Content-Length: ' . strlen($data)); - echo $data; + + return new Response($content, Response::HTTP_OK, [ + 'Content-Type' => 'application/xml', + ]); } /** - * A separate file for each family tree and each record type. - * These files depend on access levels, so only cache for visitors. + * @param Request $request * - * @param int $ged_id - * @param string $rec_type - * @param string $volume + * @return Response */ - private function generateFile($ged_id, $rec_type, $volume) { - $tree = Tree::findById($ged_id); - // Check the cache - $timestamp = (int) $this->getPreference('sitemap-' . $ged_id . '-' . $rec_type . '-' . $volume . '.timestamp'); - if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE && !Auth::check()) { - $data = $this->getPreference('sitemap-' . $ged_id . '-' . $rec_type . '-' . $volume . '.xml'); + public function getFileAction(Request $request): Response { + $file = $request->get('file', ''); + + if (!preg_match('/^(\d+)-([imnrs])-(\d+)$/', $file, $match)) { + throw new NotFoundHttpException('Bad sitemap file'); + } + + $timestamp = (int) $this->getPreference('sitemap-' . $file . '.timestamp'); + + if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) { + $content = $this->getPreference('sitemap-' . $file . '.xml'); } else { - $data = '<url><loc>' . WT_BASE_URL . 'index.php?ctype=gedcom&ged=' . $tree->getNameUrl() . '</loc></url>' . PHP_EOL; - $records = []; - switch ($rec_type) { - case 'i': - $rows = Database::prepare( - "SELECT i_id AS xref, i_gedcom AS gedcom" . - " FROM `##individuals`" . - " WHERE i_file = :tree_id" . - " ORDER BY i_id" . - " LIMIT :limit OFFSET :offset" - )->execute([ - 'tree_id' => $ged_id, - 'limit' => self::RECORDS_PER_VOLUME, - 'offset' => self::RECORDS_PER_VOLUME * $volume, - ])->fetchAll(); - foreach ($rows as $row) { - $records[] = Individual::getInstance($row->xref, $tree, $row->gedcom); - } - break; - case 's': - $rows = Database::prepare( - "SELECT s_id AS xref, s_gedcom AS gedcom" . - " FROM `##sources`" . - " WHERE s_file = :tree_id" . - " ORDER BY s_id" . - " LIMIT :limit OFFSET :offset" - )->execute([ - 'tree_id' => $ged_id, - 'limit' => self::RECORDS_PER_VOLUME, - 'offset' => self::RECORDS_PER_VOLUME * $volume, - ])->fetchAll(); - foreach ($rows as $row) { - $records[] = Source::getInstance($row->xref, $tree, $row->gedcom); - } - break; - case 'r': - $rows = Database::prepare( - "SELECT o_id AS xref, o_gedcom AS gedcom" . - " FROM `##other`" . - " WHERE o_file = :tree_id AND o_type = 'REPO'" . - " ORDER BY o_id" . - " LIMIT :limit OFFSET :offset" - )->execute([ - 'tree_id' => $ged_id, - 'limit' => self::RECORDS_PER_VOLUME, - 'offset' => self::RECORDS_PER_VOLUME * $volume, - ])->fetchAll(); - foreach ($rows as $row) { - $records[] = Repository::getInstance($row->xref, $tree, $row->gedcom); - } - break; - case 'n': - $rows = Database::prepare( - "SELECT o_id AS xref, o_gedcom AS gedcom" . - " FROM `##other`" . - " WHERE o_file = :tree_id AND o_type = 'NOTE'" . - " ORDER BY o_id" . - " LIMIT :limit OFFSET :offset" - )->execute([ - 'tree_id' => $ged_id, - 'limit' => self::RECORDS_PER_VOLUME, - 'offset' => self::RECORDS_PER_VOLUME * $volume, - ])->fetchAll(); - foreach ($rows as $row) { - $records[] = Note::getInstance($row->xref, $tree, $row->gedcom); - } - break; - case 'm': - $rows = Database::prepare( - "SELECT m_id AS xref, m_gedcom AS gedcom" . - " FROM `##media`" . - " WHERE m_file = :tree_id" . - " ORDER BY m_id" . - " LIMIT :limit OFFSET :offset" - )->execute([ - 'tree_id' => $ged_id, - 'limit' => self::RECORDS_PER_VOLUME, - 'offset' => self::RECORDS_PER_VOLUME * $volume, - ])->fetchAll(); - foreach ($rows as $row) { - $records[] = Media::getInstance($row->xref, $tree, $row->gedcom); - } - break; - } - foreach ($records as $record) { - if ($record->canShowName()) { - $data .= '<url>'; - $data .= '<loc>' . WT_BASE_URL . e($record->url()) . '</loc>'; - $chan = $record->getFirstFact('CHAN'); - if ($chan) { - $date = $chan->getDate(); - if ($date->isOK()) { - $data .= '<lastmod>' . $date->minimumDate()->Format('%Y-%m-%d') . '</lastmod>'; - } - } - $data .= '</url>' . PHP_EOL; - } - } - $data = '<' . '?xml version="1.0" encoding="UTF-8" ?' . '>' . PHP_EOL . '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">' . PHP_EOL . $data . '</urlset>' . PHP_EOL; - // Cache this data - but only for visitors, as we don’t want - // visitors to see data created by signed-in users. - if (!Auth::check()) { - $this->setPreference('sitemap-' . $ged_id . '-' . $rec_type . '-' . $volume . '.xml', $data); - $this->setPreference('sitemap-' . $ged_id . '-' . $rec_type . '-' . $volume . '.timestamp', WT_TIMESTAMP); + $tree = Tree::findById((int) $match[1]); + + if ($tree === null) { + throw new NotFoundHttpException('No such tree'); } + + $records = $this->sitemapRecords($tree, $match[2], self::RECORDS_PER_VOLUME, + self::RECORDS_PER_VOLUME * $match[3]); + + $content = view('modules/sitemap/sitemap-file', ['records' => $records]); + + $this->setPreference('sitemap.xml', $content); } - header('Content-Type: application/xml'); - header('Content-Length: ' . strlen($data)); - echo $data; + + return new Response($content, Response::HTTP_OK, [ + 'Content-Type' => 'application/xml', + ]); } /** - * Edit the configuration + * @param Tree $tree + * @param string $type + * @param int $limit + * @param int $offset + * + * @return array */ - private function admin() { - $controller = new PageController; - $controller - ->restrictAccess(Auth::isAdmin()) - ->setPageTitle($this->getTitle()) - ->pageHeader(); + private function sitemapRecords(Tree $tree, string $type, int $limit, int $offset): array { + switch ($type) { + case 'i': + $records = $this->sitemapIndividuals($tree, $limit, $offset); + break; - // Save the updated preferences - if (Filter::post('action') == 'save') { - foreach (Tree::getAll() as $tree) { - $tree->setPreference('include_in_sitemap', Filter::postBool('include' . $tree->getTreeId())); - } - // Clear cache and force files to be regenerated - Database::prepare( - "DELETE FROM `##module_setting` WHERE setting_name LIKE 'sitemap%'" - )->execute(); + case 'm': + $records = $this->sitemapMedia($tree, $limit, $offset); + break; + + case 'n': + $records = $this->sitemapNotes($tree, $limit, $offset); + break; + + case 'r': + $records = $this->sitemapRepositories($tree, $limit, $offset); + break; + + case 's': + $records = $this->sitemapSources($tree, $limit, $offset); + break; + + default: + throw new NotFoundHttpException('Invalid record type: ' . $type); } - $include_any = false; + // Skip records that no longer exist. + $records = array_filter($records); - echo Bootstrap4::breadcrumbs([ - route('admin-control-panel') => I18N::translate('Control panel'), - route('admin-modules') => I18N::translate('Module administration'), - ], $controller->getPageTitle()); - ?> + // Skip private records. + $records = array_filter($records, function (GedcomRecord $record) { + return $record->canShow(); + }); - <h1><?= $controller->getPageTitle() ?></h1> - <?php + return $records; + } - echo - '<p>', - /* I18N: The www.sitemaps.org site is translated into many languages (e.g. http://www.sitemaps.org/fr/) - choose an appropriate URL. */ - I18N::translate('Sitemaps are a way for webmasters to tell search engines about the pages on a website that are available for crawling. All major search engines support sitemaps. For more information, see <a href="http://www.sitemaps.org/">www.sitemaps.org</a>.') . - '</p>', - '<p>', /* I18N: Label for a configuration option */ I18N::translate('Which family trees should be included in the sitemaps'), '</p>', - '<form method="post" action="module.php?mod=' . $this->getName() . '&mod_action=admin">', - '<input type="hidden" name="action" value="save">'; - foreach (Tree::getAll() as $tree) { - echo '<div class="form-check"><label><input type="checkbox" name="include', $tree->getTreeId(), '" '; - if ($tree->getPreference('include_in_sitemap')) { - echo 'checked'; - $include_any = true; - } - echo '>', e($tree->getTitle()), '</label></div>'; + /** + * @param Tree $tree + * @param int $limit + * @param int $offset + * + * @return array + */ + private function sitemapIndividuals(Tree $tree, int $limit, int $offset): array { + $rows = Database::prepare( + "SELECT i_id AS xref, i_gedcom AS gedcom" . + " FROM `##individuals`" . + " WHERE i_file = :tree_id" . + " ORDER BY i_id" . + " LIMIT :limit OFFSET :offset" + )->execute([ + 'tree_id' => $tree->getTreeId(), + 'limit' => $limit, + 'offset' => $offset, + ])->fetchAll(); + + $records = []; + + foreach ($rows as $row) { + $records[] = Individual::getInstance($row->xref, $tree, $row->gedcom); } - echo - '<input type="submit" value="', I18N::translate('save'), '">', - '</form>', - '<hr>'; - if ($include_any) { - $site_map_url1 = WT_BASE_URL . 'module.php?mod=' . $this->getName() . '&mod_action=generate&file=sitemap.xml'; - $site_map_url2 = rawurlencode(WT_BASE_URL . 'module.php?mod=' . $this->getName() . '&mod_action=generate&file=sitemap.xml'); - echo - '<p>', I18N::translate('To tell search engines that sitemaps are available, you should add the following line to your robots.txt file.'), '</p>', - '<pre>Sitemap: ', $site_map_url1, '</pre>', - '<hr>', - '<p>', I18N::translate('To tell search engines that sitemaps are available, you can use the following links.'), '</p>', - '<ul>', - // This list comes from http://en.wikipedia.org/wiki/Sitemaps - '<li><a href="https://www.bing.com/webmaster/ping.aspx?siteMap=' . $site_map_url2 . '">Bing</a></li>', - '<li><a href="https://www.google.com/webmasters/tools/ping?sitemap=' . $site_map_url2 . '">Google</a></li>', - '</ul>'; + return $records; + } + + /** + * @param Tree $tree + * @param int $limit + * @param int $offset + * + * @return array + */ + private function sitemapMedia(Tree $tree, int $limit, int $offset): array { + $rows = Database::prepare( + "SELECT m_id AS xref, m_gedcom AS gedcom" . + " FROM `##media`" . + " WHERE m_file = :tree_id" . + " ORDER BY m_id" . + " LIMIT :limit OFFSET :offset" + )->execute([ + 'tree_id' => $tree->getTreeId(), + 'limit' => $limit, + 'offset' => $offset, + ])->fetchAll(); + + $records = []; + + foreach ($rows as $row) { + $records[] = Media::getInstance($row->xref, $tree, $row->gedcom); } + + return $records; } - /** {@inheritdoc} */ - public function getConfigLink() { - return Html::url('module.php', [ - 'mod' => $this->getName(), - 'mod_action' => 'admin', - ]); + /** + * @param Tree $tree + * @param int $limit + * @param int $offset + * + * @return array + */ + private function sitemapNotes(Tree $tree, int $limit, int $offset): array { + $rows = Database::prepare( + "SELECT o_id AS xref, o_gedcom AS gedcom" . + " FROM `##other`" . + " WHERE o_file = :tree_id AND o_type = 'NOTE'" . + " ORDER BY o_id" . + " LIMIT :limit OFFSET :offset" + )->execute([ + 'tree_id' => $tree->getTreeId(), + 'limit' => $limit, + 'offset' => $offset, + ])->fetchAll(); + + $records = []; + + foreach ($rows as $row) { + $records[] = Note::getInstance($row->xref, $tree, $row->gedcom); + } + + return $records; + } + + /** + * @param Tree $tree + * @param int $limit + * @param int $offset + * + * @return array + */ + private function sitemapRepositories(Tree $tree, int $limit, int $offset): array { + $rows = Database::prepare( + "SELECT o_id AS xref, o_gedcom AS gedcom" . + " FROM `##other`" . + " WHERE o_file = :tree_id AND o_type = 'REPO'" . + " ORDER BY o_id" . + " LIMIT :limit OFFSET :offset" + )->execute([ + 'tree_id' => $tree->getTreeId(), + 'limit' => $limit, + 'offset' => $offset, + ])->fetchAll(); + + $records = []; + + foreach ($rows as $row) { + $records[] = Repository::getInstance($row->xref, $tree, $row->gedcom); + } + + return $records; + } + + /** + * @param Tree $tree + * @param int $limit + * @param int $offset + * + * @return array + */ + private function sitemapSources(Tree $tree, int $limit, int $offset): array { + $rows = Database::prepare( + "SELECT s_id AS xref, s_gedcom AS gedcom" . + " FROM `##sources`" . + " WHERE s_file = :tree_id" . + " ORDER BY s_id" . + " LIMIT :limit OFFSET :offset" + )->execute([ + 'tree_id' => $tree->getTreeId(), + 'limit' => $limit, + 'offset' => $offset, + ])->fetchAll(); + + $records = []; + + foreach ($rows as $row) { + $records[] = Source::getInstance($row->xref, $tree, $row->gedcom); + } + + return $records; } } |
