SproutCMS

This is the code documentation for the SproutCMS project

source of /sprout/Controllers/PageController.php

  1. <?php
  2. /*
  3.  * Copyright (C) 2017 Karmabunny Pty Ltd.
  4.  *
  5.  * This file is a part of SproutCMS.
  6.  *
  7.  * SproutCMS is free software: you can redistribute it and/or modify it under the terms
  8.  * of the GNU General Public License as published by the Free Software Foundation, either
  9.  * version 2 of the License, or (at your option) any later version.
  10.  *
  11.  * For more information, visit <http://getsproutcms.com>.
  12.  */
  13.  
  14. namespace Sprout\Controllers;
  15.  
  16. use Exception;
  17.  
  18. use Kohana;
  19. use Kohana_404_Exception;
  20.  
  21. use karmabunny\pdb\Exceptions\RowMissingException;
  22. use Sprout\Helpers\AdminAuth;
  23. use Sprout\Helpers\ContentReplace;
  24. use Sprout\Helpers\Csrf;
  25. use Sprout\Helpers\Email;
  26. use Sprout\Helpers\FrontEndSearch;
  27. use Sprout\Helpers\Lnk;
  28. use Sprout\Helpers\Navigation;
  29. use Sprout\Helpers\Needs;
  30. use Sprout\Helpers\Notification;
  31. use Sprout\Helpers\Page;
  32. use Sprout\Helpers\Pdb;
  33. use Sprout\Helpers\Request;
  34. use Sprout\Helpers\Router;
  35. use Sprout\Helpers\SocialMeta;
  36. use Sprout\Helpers\SocialNetworking;
  37. use Sprout\Helpers\Sprout;
  38. use Sprout\Helpers\SubsiteSelector;
  39. use Sprout\Helpers\Subsites;
  40. use Sprout\Helpers\Tags;
  41. use Sprout\Helpers\Text;
  42. use Sprout\Helpers\TreenodePathMatcher;
  43. use Sprout\Helpers\TreenodeValueMatcher;
  44. use Sprout\Helpers\TwigView;
  45. use Sprout\Helpers\Url;
  46. use Sprout\Helpers\UserPerms;
  47. use Sprout\Helpers\View;
  48. use Sprout\Helpers\Widgets;
  49.  
  50.  
  51. /**
  52.  * Handles front-end processing for pages
  53.  */
  54. class PageController extends Controller implements FrontEndSearch
  55. {
  56. private $navigation_node = null;
  57.  
  58. /**
  59.   * 404 error
  60.   **/
  61. public function fourOhFour($name)
  62. {
  63. throw new Kohana_404_Exception('"' . $name . '"');
  64. }
  65.  
  66.  
  67. /**
  68.   * Displays a page.
  69.   * The page is specified by the page friendly name
  70.   *
  71.   * @param string $name The URL of the page to display
  72.   **/
  73. public function viewByName($name)
  74. {
  75. $root = Navigation::getRootNode();
  76.  
  77. $matcher = new TreenodePathMatcher($name);
  78. $node = $root->findNode($matcher);
  79. $this->navigation_node = $node;
  80.  
  81. if (! $node) {
  82. throw new Kohana_404_Exception('"' . $name . '"');
  83. }
  84.  
  85. $this->viewById($node['id']);
  86. }
  87.  
  88.  
  89. /**
  90.   * Displays a page.
  91.   * The page is specified by the page id
  92.   *
  93.   * @param int $id The id of the page to display
  94.   **/
  95. public function viewById($id)
  96. {
  97. $id = (int) $id;
  98.  
  99. // Prep where clauses
  100. $where = [];
  101. $params = [];
  102. $where[] = 'pages.id = :page_id';
  103. $params['page_id'] = $id;
  104. $where[] = 'pages.active = 1';
  105.  
  106. if (! SubsiteSelector::$mobile) {
  107. $where[] = 'pages.subsite_id = :subsite_id';
  108. $params['subsite_id'] = SubsiteSelector::$content_id;
  109. }
  110.  
  111. // Do query
  112. $where = implode(' AND ', $where);
  113. $q = "SELECT pages.id, pages.name, pages.meta_keywords, pages.meta_description,
  114. pages.alt_browser_title, revs.id AS rev_id, pages.alt_template, pages.subsite_id,
  115. pages.additional_css AS has_additional_css,
  116. pages.gallery_thumb, pages.banner,
  117. revs.type, revs.redirect, revs.date_modified,
  118. revs.controller_entrance, revs.controller_argument
  119. FROM ~pages AS pages
  120. INNER JOIN ~page_revisions AS revs
  121. ON revs.page_id = pages.id
  122. AND revs.status = :status
  123. WHERE {$where}";
  124. $params['status'] = 'live';
  125. try {
  126. $page = Pdb::q($q, $params, 'row');
  127. } catch (RowMissingException $ex) {
  128. throw new Kohana_404_Exception("Page # {$id}");
  129. }
  130.  
  131. $root = Navigation::getRootNode();
  132. $node = $root->findNodeValue('id', $id);
  133. $this->navigation_node = $node;
  134.  
  135. if (Kohana::config('sprout.page_stats')) {
  136. $this->trackVisit($id);
  137. }
  138.  
  139. if ($page['type'] == 'redirect') {
  140. // URL redirect
  141. Url::redirect(Lnk::url($page['redirect']));
  142.  
  143. } else if ($page['type'] == 'tool') {
  144. // Front-end controller entrance
  145. $inst = Sprout::instance(
  146. $page['controller_entrance'],
  147. ['Sprout\\Controllers\\Controller', 'Sprout\\Helpers\\FrontEndEntrance']
  148. );
  149.  
  150. $conds_env = $this->widgetCondsEnvironment($page);
  151. $this->loadWidgets($conds_env, $page);
  152.  
  153. Router::$controller = $page['controller_entrance'];
  154.  
  155. $inst->entrance($page['controller_argument']);
  156.  
  157. } else {
  158. // Standard page
  159. echo $this->displayPage($page);
  160. }
  161. }
  162.  
  163.  
  164. /**
  165.   * Displays a specific revision of a page.
  166.   * @param int $page_id The ID of the page to display
  167.   * @param int $rev_id The ID of the revision to display.
  168.   * The revision must be live, or the user must be an administrator,
  169.   * or the correct approval_code for the revision must be provided.
  170.   * @param string $approval_code Code to view the revision without authentication, e.g. via emailed link.
  171.   * If not provided, the revision must be live, or the user must be logged in as an operator.
  172.   */
  173. public function viewSpecificRev($page_id, $rev_id, $approval_code = '')
  174. {
  175. $page_id = (int) $page_id;
  176. $rev_id = (int) $rev_id;
  177.  
  178. // Fetch revision from DB
  179. $params = ['page_id' => $page_id, 'rev_id' => $rev_id];
  180. $code_clause = '';
  181. if ($approval_code != '') {
  182. $code_clause = 'AND rev.approval_code = :approval_code';
  183. $params['approval_code'] = $approval_code;
  184. }
  185. $q = "SELECT page.id, page.name, page.meta_keywords, page.meta_description, page.alt_browser_title,
  186. page.alt_template, page.subsite_id,
  187. rev.status, rev.id AS rev_id, rev.status, rev.modified_editor, rev.date_modified
  188. FROM ~pages AS page
  189. INNER JOIN ~page_revisions AS rev ON page.id = rev.page_id
  190. AND rev.id = :rev_id {$code_clause}
  191. WHERE page.id = :page_id";
  192. try {
  193. $page = Pdb::q($q, $params, 'row');
  194. } catch (RowMissingException $ex) {
  195. throw new Kohana_404_Exception("Page # {$page_id}, rev # {$rev_id}");
  196. }
  197.  
  198. // Verify that the user has rights to view this revision
  199. if ($approval_code == '' and $page['status'] != 'live') {
  200. AdminAuth::checkLogin();
  201. }
  202.  
  203. echo $this->displayPage($page, $approval_code);
  204. }
  205.  
  206.  
  207. /**
  208.   * Rudimentary stats tracking
  209.   **/
  210. private function trackVisit($page_id)
  211. {
  212. try {
  213. $sect_id = 0;
  214. if ($this->navigation_node) {
  215. $anc = $this->navigation_node->findAncestors();
  216. $sect_id = $anc[0]['id'];
  217. }
  218.  
  219. $q = "INSERT INTO ~page_visits
  220. SET page_id = ?, section_page_id = ?, date_hits = CURDATE(), num = 1
  221. ON DUPLICATE KEY UPDATE num = num + 1";
  222. Pdb::q($q, [$page_id, $sect_id], 'count');
  223. } catch (Exception $ex) {}
  224. }
  225.  
  226.  
  227. /**
  228.   * Called by the two display functions.
  229.   * Does the actual display.
  230.   *
  231.   * @param array $page Combined page/revision record from the DB;
  232.   * see e.g. {@see PageController::viewById} or {@see PageController::viewSpecificRev}
  233.   * @param string $approval_code Approval code, if the page needs to include 'approve' and 'deny' buttons
  234.   * for the revision being viewed
  235.   * @return string HTML
  236.   */
  237. private function displayPage(array $page, $approval_code = '')
  238. {
  239. if (Request::isAjax()) {
  240. $page_view_name = 'skin/popup';
  241. } else if (!empty($page['alt_template'])) {
  242. $page_view_name = $page['alt_template'];
  243. } else {
  244. $page_view_name = 'skin/inner';
  245. }
  246.  
  247. $page_view = View::create($page_view_name);
  248.  
  249. // Load navigation
  250. Navigation::setPageNodeMatcher(new TreenodeValueMatcher('id', $page['id']));
  251. if (! $this->navigation_node) {
  252. $this->navigation_node = Navigation::getRootNode()->findNodeValue('id', $page['id']);
  253. }
  254.  
  255. // Page titles
  256. $page_view->page_title = $page['name'];
  257. if ($page['alt_browser_title']) {
  258. $page_view->browser_title = $page['alt_browser_title'];
  259. } else {
  260. $page_view->browser_title = Navigation::buildBrowserTitle($page['name']);
  261. }
  262.  
  263. // Load the ancestors for the page node and get the name (and url name) of the top-level ancestor
  264. if ($this->navigation_node) {
  265. $anc = $this->navigation_node->findAncestors();
  266. $page_view->top_level_name = $anc[0]->getNavigationName();
  267. $page_view->top_level_urlname = $anc[0]->getUrlName();
  268. }
  269.  
  270. // If we don't have access to this page, show a login form
  271. if (! UserPerms::checkPermissionsTree('pages', $page['id'])) {
  272. $page_view->main_content = UserPerms::getAccessDenied();
  273. $_GET['redirect'] = Url::current(true);
  274. return $page_view->render();
  275. }
  276.  
  277. // Get list of widgets and render their content
  278. $conds_env = $this->widgetCondsEnvironment($page);
  279. $this->loadWidgets($conds_env, $page);
  280. $page_view->main_content = Widgets::renderArea('embedded');
  281.  
  282. // Inject approval form above content
  283. if (@$page['status'] == 'need_approval' and $approval_code) {
  284. $form_view = new View('sprout/page_approval_form');
  285. $form_view->rev_id = (int) $page['rev_id'];
  286. $form_view->code = $approval_code;
  287. $page_view->main_content = $form_view->render() . $page_view->main_content;
  288.  
  289. } else if (isset($page['status']) and @$page['status'] != 'live') {
  290. // Inject a view with info about the revision
  291. $info_view = new View('sprout/page_rev_info');
  292. $info_view->page = $page;
  293. $page_view->main_content = $info_view->render() . $page_view->main_content;
  294. }
  295.  
  296. SocialNetworking::details($page['name'], $page_view->main_content);
  297. $this->setSocialMeta($page, $page_view->main_content);
  298.  
  299. if (Kohana::config('sprout.tweak_skin') and $page['has_additional_css']) {
  300. Needs::addCssInclude('SITE/page/additional_css/' . $page['id'] . '/' . strtotime($page['date_modified']) . '.css');
  301. }
  302.  
  303. // Metadata
  304. if ($page['meta_keywords']) Needs::addMeta('keywords', $page['meta_keywords']);
  305. if ($page['meta_description']) Needs::addMeta('description', $page['meta_description']);
  306.  
  307. $page_view->page_attrs = Page::attrs($page['id']);
  308. $page_view->tags = Tags::byRecord('pages', $page['id']);
  309. $page_view->controller_name = $this->getCssClassName();
  310. $page_view->canonical_url = Page::url($page['id']);
  311.  
  312. return $page_view->render();
  313. }
  314.  
  315.  
  316. /**
  317.   * Set the social meta data for a page
  318.   *
  319.   * @param array $page Page details from the database
  320.   * @param string $content_html The rendered content html
  321.   * @return void Sets values in the {@see SocialMeta} helper
  322.   */
  323. private function setSocialMeta(array $page, $content_html)
  324. {
  325. SocialMeta::setTitle($page['name']);
  326.  
  327. if (!empty($page['gallery_thumb'])) {
  328. SocialMeta::setImage($page['gallery_thumb']);
  329. } else if (!empty($page['banner'])) {
  330. SocialMeta::setImage($page['banner']);
  331. } else {
  332. // Attempt to scrape the first image from the content
  333. $matches = null;
  334. if (preg_match('!<img .+?>!', $content_html, $matches)) {
  335. if (preg_match('!src="([^"]+)"!', $matches[0], $matches)) {
  336. SocialMeta::setImage($matches[1]);
  337. }
  338. }
  339. }
  340.  
  341. if ($page['meta_description']) {
  342. SocialMeta::setDescription($page['meta_description']);
  343. } else {
  344. $capped = Text::plain($content_html, 20);
  345. $capped = str_replace(["\r", "\n"], ' ', $capped);
  346. SocialMeta::setDescription($capped);
  347. }
  348.  
  349. if (!empty($this->navigation_node)) {
  350. SocialMeta::setUrl($this->navigation_node->getFriendlyUrlNoPrefix());
  351. }
  352. }
  353.  
  354.  
  355. /**
  356.   * Makes alterations to the main text content
  357.   **/
  358. private function textTranslation($page_id, $text)
  359. {
  360. $text = ContentReplace::intlinks($text);
  361. $text = ContentReplace::embedWidgets($text, 'page', $page_id);
  362. $text = ContentReplace::localanchor($text);
  363. return $text;
  364. }
  365.  
  366.  
  367. /**
  368.   * Return the environment which is provided to the widget display conditions logic
  369.   *
  370.   * @param array $page Database record
  371.   * @return array Environment which gets passed to {@see Widgets::checkDisplayConditions}
  372.   */
  373. private function widgetCondsEnvironment(array $page)
  374. {
  375. return [
  376. 'page_id' => $page['id'],
  377. ];
  378. }
  379.  
  380.  
  381. /**
  382.   * Loads the widgets from the database for this page.
  383.   *
  384.   * @param array $page The page to load widgets from
  385.   **/
  386. private function loadWidgets(array $conds_env, array $page)
  387. {
  388. $q = "SELECT area_id, type, settings, conditions, heading, template
  389. FROM ~page_widgets
  390. WHERE page_revision_id = ? AND active = 1
  391. ORDER BY area_id, record_order";
  392. $wids = Pdb::q($q, [$page['rev_id']], 'arr');
  393.  
  394. foreach ($wids as $widget) {
  395. $settings = json_decode($widget['settings'], true);
  396.  
  397. $conditions = json_decode($widget['conditions'], true);
  398. if (!empty($conditions)) {
  399. $result = Widgets::checkDisplayConditions($conds_env, $conditions);
  400. if (!$result) {
  401. continue;
  402. }
  403. }
  404.  
  405. Widgets::add($widget['area_id'], $widget['type'], $settings, $widget['heading'], $widget['template']);
  406. }
  407. }
  408.  
  409.  
  410. /**
  411.   * Gets the additional CSS for a page, if it has any
  412.   **/
  413. public function additionalCss($page_id, $junk = null)
  414. {
  415. $page_id = (int) $page_id;
  416.  
  417. $q = "SELECT additional_css FROM ~pages WHERE id = ?";
  418. $row = Pdb::q($q, [$page_id], 'row');
  419.  
  420. header('Content-type: text/css; charset=UTF-8');
  421. echo $row['additional_css'];
  422. }
  423.  
  424.  
  425. /**
  426.   * Process the results of a search.
  427.   *
  428.   * @param array $row A single row of data to output
  429.   * @return string The result string
  430.   **/
  431. public function frontEndSearch($item_id, $relevancy, $keywords)
  432. {
  433. $root = Navigation::getRootNode();
  434. $node = $root->findNodeValue('id', $item_id);
  435. if (! $node) return false;
  436.  
  437. $name = $node->getNavigationName();
  438. $url = $node->getFriendlyUrl();
  439.  
  440. // Collate widgets to produce page text
  441. $text = Page::getText($item_id);
  442.  
  443. $text = Text::plain($text, 0);
  444. $text = substr($text, 0, 5000);
  445.  
  446. if ($text == '') return false;
  447.  
  448. // Look for the first keyword in the text
  449. $pos = 5000;
  450. $matches = null;
  451. foreach ($keywords as $k) {
  452. $k = preg_quote($k);
  453. if (preg_match("/(^|\W){$k}($|\W)/i", $text, $matches, PREG_OFFSET_CAPTURE)) {
  454. $pos = min($pos, $matches[0][1]);
  455. }
  456. }
  457.  
  458. // If anything was found in first 5000 chars, show that bit
  459. if ($pos < 5000) {
  460. $pos -= 10;
  461. if ($pos > 1) {
  462. $text = '...' . substr($text, $pos);
  463. }
  464. }
  465.  
  466. // Limit to something more reasonable
  467. $text = Text::limitWords($text, 40, '...');
  468.  
  469. // Bolden keywords
  470. foreach ($keywords as $k) {
  471. $k = preg_quote($k);
  472. $name = preg_replace("/(^|\W)({$k})($|\W)/i", '$1<b>$2</b>$3', $name);
  473. $text = preg_replace("/(^|\W)({$k})($|\W)/i", '$1<b>$2</b>$3', $text);
  474. }
  475.  
  476. $view = new View('sprout/search_results_page');
  477. $view->name = $name;
  478. $view->url = $url;
  479. $view->text = $text;
  480. $view->relevancy = $relevancy;
  481.  
  482. return $view->render();
  483. }
  484.  
  485.  
  486. /**
  487.   * Action for reviewing a page - either approves or rejects the revision
  488.   */
  489. function review($rev_id)
  490. {
  491. Csrf::checkOrDie();
  492.  
  493. $rev_id = (int) $rev_id;
  494. $code = (string) $_POST['code'];
  495.  
  496. if (@$_POST['do'] == 'approve') {
  497. $approve = true;
  498. } else if (@$_POST['do'] == 'reject') {
  499. $approve = false;
  500. } else {
  501. Notification::error('Unknown action');
  502. Url::redirect();
  503. }
  504.  
  505. if ($code == '') {
  506. Notification::error('Invalid approval code');
  507. Url::redirect();
  508. }
  509.  
  510. try {
  511. $q = "SELECT rev.id, rev.status, rev.page_id, page.name AS page_name, page.subsite_id,
  512. op.id AS op_id, op.email, op.name AS op_name
  513. FROM ~page_revisions AS rev
  514. INNER JOIN ~pages AS page ON rev.page_id = page.id
  515. LEFT JOIN ~operators AS op ON rev.operator_id = op.id
  516. WHERE rev.id = ? AND rev.approval_code = ?";
  517. $rev = Pdb::q($q, [$rev_id, $code], 'row');
  518. } catch (RowMissingException $ex) {
  519. Notification::error('Invalid revision or approval code');
  520. Url::redirect();
  521. }
  522.  
  523. if ($approve and $rev['status'] == 'live') {
  524. Notification::confirm('Revision is already live');
  525. Url::redirect(Page::url($rev['page_id']));
  526. } else if ($rev['status'] != 'need_approval') {
  527. Notification::error('Revision is not awaiting approval');
  528. Url::redirect();
  529. }
  530.  
  531. if ($approve) {
  532. Pdb::transact();
  533. Page::activateRevision($rev_id);
  534. Pdb::commit();
  535.  
  536. // N.B. Fetch URL after updating DB, as slugs may change with revisions
  537. $url = Subsites::getAbsRoot($rev['subsite_id']) . Page::url($rev['page_id']);
  538.  
  539. // Send an email to the operator who requested the change
  540. if ($rev['op_id'] > 0) {
  541. $view = new View('sprout/email/page_approved');
  542. $view->addressee = preg_replace('/\s.*/', '', trim($rev['op_name']));
  543. $view->page_name = $rev['page_name'];
  544. $view->url = $url;
  545. $view->message = @$_POST['message'];
  546.  
  547. $mail = new Email();
  548. $mail->AddAddress($rev['email']);
  549. $mail->Subject = 'Page change approved on ' . Kohana::config('sprout.site_title');
  550. $mail->SkinnedHTML($view->render());
  551. $mail->Send();
  552. }
  553.  
  554. // TODO: add history of approval
  555. Notification::confirm('Revision is now live');
  556. Url::redirect($url);
  557. } else {
  558. Pdb::update('page_revisions', ['status' => 'rejected'], ['id' => $rev_id]);
  559.  
  560. $url = Subsites::getAbsRoot($rev['subsite_id']) . Page::url($rev['page_id']);
  561.  
  562. // Send an email to the operator who requested the change
  563. if ($rev['op_id'] > 0) {
  564. $view = new View('sprout/email/page_rejected');
  565. $view->addressee = preg_replace('/\s.*/', '', trim($rev['op_name']));
  566. $view->page_name = $rev['page_name'];
  567. $view->url = $url;
  568. $view->message = @$_POST['message'];
  569.  
  570. $mail = new Email();
  571. $mail->AddAddress($rev['email']);
  572. $mail->Subject = 'Page change rejected on ' . Kohana::config('sprout.site_title');
  573. $mail->SkinnedHTML($view->render());
  574. $mail->Send();
  575. }
  576.  
  577. // TODO: add history of denial
  578. Notification::confirm('Revision has been rejected');
  579. Url::redirect($url);
  580. }
  581. }
  582.  
  583. }
  584.