<?php
/*
* Copyright (C) 2017 Karmabunny Pty Ltd.
*
* This file is a part of SproutCMS.
*
* SproutCMS is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation, either
* version 2 of the License, or (at your option) any later version.
*
* For more information, visit <http://getsproutcms.com>.
*/
namespace Sprout\Controllers;
use Exception;
use Kohana;
use Kohana_404_Exception;
use karmabunny\pdb\Exceptions\RowMissingException;
use Sprout\Helpers\AdminAuth;
use Sprout\Helpers\ContentReplace;
use Sprout\Helpers\Csrf;
use Sprout\Helpers\Email;
use Sprout\Helpers\FrontEndSearch;
use Sprout\Helpers\Lnk;
use Sprout\Helpers\Navigation;
use Sprout\Helpers\Needs;
use Sprout\Helpers\Notification;
use Sprout\Helpers\Page;
use Sprout\Helpers\Pdb;
use Sprout\Helpers\Request;
use Sprout\Helpers\Router;
use Sprout\Helpers\SocialMeta;
use Sprout\Helpers\SocialNetworking;
use Sprout\Helpers\Sprout;
use Sprout\Helpers\SubsiteSelector;
use Sprout\Helpers\Subsites;
use Sprout\Helpers\Tags;
use Sprout\Helpers\Text;
use Sprout\Helpers\TreenodePathMatcher;
use Sprout\Helpers\TreenodeValueMatcher;
use Sprout\Helpers\TwigView;
use Sprout\Helpers\Url;
use Sprout\Helpers\UserPerms;
use Sprout\Helpers\View;
use Sprout\Helpers\Widgets;
/**
* Handles front-end processing for pages
*/
class PageController extends Controller implements FrontEndSearch
{
private $navigation_node = null;
/**
* 404 error
**/
public function fourOhFour($name)
{
throw new Kohana_404_Exception('"' . $name . '"');
}
/**
* Displays a page.
* The page is specified by the page friendly name
*
* @param string $name The URL of the page to display
**/
public function viewByName($name)
{
$root = Navigation::getRootNode();
$matcher = new TreenodePathMatcher($name);
$node = $root->findNode($matcher);
$this->navigation_node = $node;
if (! $node) {
throw new Kohana_404_Exception('"' . $name . '"');
}
$this->viewById($node['id']);
}
/**
* Displays a page.
* The page is specified by the page id
*
* @param int $id The id of the page to display
**/
public function viewById($id)
{
$id = (int) $id;
// Prep where clauses
$where = [];
$params = [];
$where[] = 'pages.id = :page_id';
$params['page_id'] = $id;
$where[] = 'pages.active = 1';
if (! SubsiteSelector::$mobile) {
$where[] = 'pages.subsite_id = :subsite_id';
$params['subsite_id'] = SubsiteSelector::$content_id;
}
// Do query
$q = "SELECT pages.id, pages.name, pages.meta_keywords, pages.meta_description,
pages.alt_browser_title, revs.id AS rev_id, pages.alt_template, pages.subsite_id,
pages.additional_css AS has_additional_css,
pages.gallery_thumb, pages.banner,
revs.type, revs.redirect, revs.date_modified,
revs.controller_entrance, revs.controller_argument
FROM ~pages AS pages
INNER JOIN ~page_revisions AS revs
ON revs.page_id = pages.id
AND revs.status = :status
WHERE {$where}";
$params['status'] = 'live';
try {
$page = Pdb::q($q, $params, 'row');
} catch (RowMissingException $ex) {
throw new Kohana_404_Exception("Page # {$id}");
}
$root = Navigation::getRootNode();
$node = $root->findNodeValue('id', $id);
$this->navigation_node = $node;
if (Kohana::config('sprout.page_stats')) {
$this->trackVisit($id);
}
if ($page['type'] == 'redirect') {
// URL redirect
Url::redirect(Lnk::url($page['redirect']));
} else if ($page['type'] == 'tool') {
// Front-end controller entrance
$inst = Sprout::instance(
$page['controller_entrance'],
['Sprout\\Controllers\\Controller', 'Sprout\\Helpers\\FrontEndEntrance']
);
$conds_env = $this->widgetCondsEnvironment($page);
$this->loadWidgets($conds_env, $page);
Router::$controller = $page['controller_entrance'];
$inst->entrance($page['controller_argument']);
} else {
// Standard page
echo $this->displayPage($page);
}
}
/**
* Displays a specific revision of a page.
* @param int $page_id The ID of the page to display
* @param int $rev_id The ID of the revision to display.
* The revision must be live, or the user must be an administrator,
* or the correct approval_code for the revision must be provided.
* @param string $approval_code Code to view the revision without authentication, e.g. via emailed link.
* If not provided, the revision must be live, or the user must be logged in as an operator.
*/
public function viewSpecificRev($page_id, $rev_id, $approval_code = '')
{
$page_id = (int) $page_id;
$rev_id = (int) $rev_id;
// Fetch revision from DB
$params = ['page_id' => $page_id, 'rev_id' => $rev_id];
$code_clause = '';
if ($approval_code != '') {
$code_clause = 'AND rev.approval_code = :approval_code';
$params['approval_code'] = $approval_code;
}
$q = "SELECT page.id, page.name, page.meta_keywords, page.meta_description, page.alt_browser_title,
page.alt_template, page.subsite_id,
rev.status, rev.id AS rev_id, rev.status, rev.modified_editor, rev.date_modified
FROM ~pages AS page
INNER JOIN ~page_revisions AS rev ON page.id = rev.page_id
AND rev.id = :rev_id {$code_clause}
WHERE page.id = :page_id";
try {
$page = Pdb::q($q, $params, 'row');
} catch (RowMissingException $ex) {
throw new Kohana_404_Exception("Page # {$page_id}, rev # {$rev_id}");
}
// Verify that the user has rights to view this revision
if ($approval_code == '' and $page['status'] != 'live') {
AdminAuth::checkLogin();
}
echo $this->displayPage($page, $approval_code);
}
/**
* Rudimentary stats tracking
**/
private function trackVisit($page_id)
{
try {
$sect_id = 0;
if ($this->navigation_node) {
$anc = $this->navigation_node->findAncestors();
$sect_id = $anc[0]['id'];
}
$q = "INSERT INTO ~page_visits
SET page_id = ?, section_page_id = ?, date_hits = CURDATE(), num = 1
ON DUPLICATE KEY UPDATE num = num + 1";
Pdb::q($q, [$page_id, $sect_id], 'count');
} catch (Exception $ex) {}
}
/**
* Called by the two display functions.
* Does the actual display.
*
* @param array $page Combined page/revision record from the DB;
* see e.g. {@see PageController::viewById} or {@see PageController::viewSpecificRev}
* @param string $approval_code Approval code, if the page needs to include 'approve' and 'deny' buttons
* for the revision being viewed
* @return string HTML
*/
private function displayPage
(array $page, $approval_code = '') {
if (Request::isAjax()) {
$page_view_name = 'skin/popup';
} else if (!empty($page['alt_template'])) { $page_view_name = $page['alt_template'];
} else {
$page_view_name = 'skin/inner';
}
$page_view = View::create($page_view_name);
// Load navigation
Navigation::setPageNodeMatcher(new TreenodeValueMatcher('id', $page['id']));
if (! $this->navigation_node) {
$this->navigation_node = Navigation::getRootNode()->findNodeValue('id', $page['id']);
}
// Page titles
$page_view->page_title = $page['name'];
if ($page['alt_browser_title']) {
$page_view->browser_title = $page['alt_browser_title'];
} else {
$page_view->browser_title = Navigation::buildBrowserTitle($page['name']);
}
// Load the ancestors for the page node and get the name (and url name) of the top-level ancestor
if ($this->navigation_node) {
$anc = $this->navigation_node->findAncestors();
$page_view->top_level_name = $anc[0]->getNavigationName();
$page_view->top_level_urlname = $anc[0]->getUrlName();
}
// If we don't have access to this page, show a login form
if (! UserPerms::checkPermissionsTree('pages', $page['id'])) {
$page_view->main_content = UserPerms::getAccessDenied();
$_GET['redirect'] = Url
::current(true); return $page_view->render();
}
// Get list of widgets and render their content
$conds_env = $this->widgetCondsEnvironment($page);
$this->loadWidgets($conds_env, $page);
$page_view->main_content = Widgets::renderArea('embedded');
// Inject approval form above content
if (@$page['status'] == 'need_approval' and $approval_code) {
$form_view = new View('sprout/page_approval_form');
$form_view->rev_id = (int) $page['rev_id'];
$form_view->code = $approval_code;
$page_view->main_content = $form_view->render() . $page_view->main_content;
} else if (isset($page['status']) and
@$page['status'] != 'live') { // Inject a view with info about the revision
$info_view = new View('sprout/page_rev_info');
$info_view->page = $page;
$page_view->main_content = $info_view->render() . $page_view->main_content;
}
SocialNetworking::details($page['name'], $page_view->main_content);
$this->setSocialMeta($page, $page_view->main_content);
if (Kohana::config('sprout.tweak_skin') and $page['has_additional_css']) {
Needs
::addCssInclude('SITE/page/additional_css/' . $page['id'] . '/' . strtotime($page['date_modified']) . '.css'); }
// Metadata
if ($page['meta_keywords']) Needs::addMeta('keywords', $page['meta_keywords']);
if ($page['meta_description']) Needs::addMeta('description', $page['meta_description']);
$page_view->page_attrs = Page::attrs($page['id']);
$page_view->tags = Tags::byRecord('pages', $page['id']);
$page_view->controller_name = $this->getCssClassName();
$page_view->canonical_url = Page::url($page['id']);
return $page_view->render();
}
/**
* Set the social meta data for a page
*
* @param array $page Page details from the database
* @param string $content_html The rendered content html
* @return void Sets values in the {@see SocialMeta} helper
*/
private function setSocialMeta
(array $page, $content_html) {
SocialMeta::setTitle($page['name']);
if (!empty($page['gallery_thumb'])) { SocialMeta::setImage($page['gallery_thumb']);
} else if (!empty($page['banner'])) { SocialMeta::setImage($page['banner']);
} else {
// Attempt to scrape the first image from the content
$matches = null;
if (preg_match('!<img .+?>!', $content_html, $matches)) { if (preg_match('!src="([^"]+)"!', $matches[0], $matches)) { SocialMeta::setImage($matches[1]);
}
}
}
if ($page['meta_description']) {
SocialMeta::setDescription($page['meta_description']);
} else {
$capped = Text::plain($content_html, 20);
SocialMeta::setDescription($capped);
}
if (!empty($this->navigation_node)) { SocialMeta::setUrl($this->navigation_node->getFriendlyUrlNoPrefix());
}
}
/**
* Makes alterations to the main text content
**/
private function textTranslation($page_id, $text)
{
$text = ContentReplace::intlinks($text);
$text = ContentReplace::embedWidgets($text, 'page', $page_id);
$text = ContentReplace::localanchor($text);
return $text;
}
/**
* Return the environment which is provided to the widget display conditions logic
*
* @param array $page Database record
* @return array Environment which gets passed to {@see Widgets::checkDisplayConditions}
*/
private function widgetCondsEnvironment
(array $page) {
return [
'page_id' => $page['id'],
];
}
/**
* Loads the widgets from the database for this page.
*
* @param array $page The page to load widgets from
**/
private function loadWidgets
(array $conds_env, array $page) {
$q = "SELECT area_id, type, settings, conditions, heading, template
FROM ~page_widgets
WHERE page_revision_id = ? AND active = 1
ORDER BY area_id, record_order";
$wids = Pdb::q($q, [$page['rev_id']], 'arr');
foreach ($wids as $widget) {
$conditions = json_decode($widget['conditions'], true); if (!empty($conditions)) { $result = Widgets::checkDisplayConditions($conds_env, $conditions);
if (!$result) {
continue;
}
}
Widgets::add($widget['area_id'], $widget['type'], $settings, $widget['heading'], $widget['template']);
}
}
/**
* Gets the additional CSS for a page, if it has any
**/
public function additionalCss($page_id, $junk = null)
{
$page_id = (int) $page_id;
$q = "SELECT additional_css FROM ~pages WHERE id = ?";
$row = Pdb::q($q, [$page_id], 'row');
header('Content-type: text/css; charset=UTF-8'); echo $row['additional_css'];
}
/**
* Process the results of a search.
*
* @param array $row A single row of data to output
* @return string The result string
**/
public function frontEndSearch($item_id, $relevancy, $keywords)
{
$root = Navigation::getRootNode();
$node = $root->findNodeValue('id', $item_id);
if (! $node) return false;
$name = $node->getNavigationName();
$url = $node->getFriendlyUrl();
// Collate widgets to produce page text
$text = Text::plain($text, 0);
$text = substr($text, 0, 5000);
if ($text == '') return false;
// Look for the first keyword in the text
$pos = 5000;
$matches = null;
foreach ($keywords as $k) {
if (preg_match("/(^|\W){$k}($|\W)/i", $text, $matches, PREG_OFFSET_CAPTURE
)) { $pos = min($pos, $matches[0][1]); }
}
// If anything was found in first 5000 chars, show that bit
if ($pos < 5000) {
$pos -= 10;
if ($pos > 1) {
$text = '...' . substr($text, $pos); }
}
// Limit to something more reasonable
$text = Text::limitWords($text, 40, '...');
// Bolden keywords
foreach ($keywords as $k) {
$name = preg_replace("/(^|\W)({$k})($|\W)/i", '$1<b>$2</b>$3', $name); $text = preg_replace("/(^|\W)({$k})($|\W)/i", '$1<b>$2</b>$3', $text); }
$view = new View('sprout/search_results_page');
$view->name = $name;
$view->url = $url;
$view->text = $text;
$view->relevancy = $relevancy;
return $view->render();
}
/**
* Action for reviewing a page - either approves or rejects the revision
*/
function review($rev_id)
{
Csrf::checkOrDie();
$rev_id = (int) $rev_id;
$code = (string) $_POST['code'];
if (@$_POST['do'] == 'approve') {
$approve = true;
} else if (@$_POST['do'] == 'reject') {
$approve = false;
} else {
Notification::error('Unknown action');
Url::redirect();
}
if ($code == '') {
Notification::error('Invalid approval code');
Url::redirect();
}
try {
$q = "SELECT rev.id, rev.status, rev.page_id, page.name AS page_name, page.subsite_id,
op.id AS op_id, op.email, op.name AS op_name
FROM ~page_revisions AS rev
INNER JOIN ~pages AS page ON rev.page_id = page.id
LEFT JOIN ~operators AS op ON rev.operator_id = op.id
WHERE rev.id = ? AND rev.approval_code = ?";
$rev = Pdb::q($q, [$rev_id, $code], 'row');
} catch (RowMissingException $ex) {
Notification::error('Invalid revision or approval code');
Url::redirect();
}
if ($approve and $rev['status'] == 'live') {
Notification::confirm('Revision is already live');
Url::redirect(Page::url($rev['page_id']));
} else if ($rev['status'] != 'need_approval') {
Notification::error('Revision is not awaiting approval');
Url::redirect();
}
if ($approve) {
Pdb::transact();
Page::activateRevision($rev_id);
Pdb::commit();
// N.B. Fetch URL after updating DB, as slugs may change with revisions
$url = Subsites::getAbsRoot($rev['subsite_id']) . Page::url($rev['page_id']);
// Send an email to the operator who requested the change
if ($rev['op_id'] > 0) {
$view = new View('sprout/email/page_approved');
$view->page_name = $rev['page_name'];
$view->url = $url;
$view->message = @$_POST['message'];
$mail = new Email();
$mail->AddAddress($rev['email']);
$mail->Subject = 'Page change approved on ' . Kohana::config('sprout.site_title');
$mail->SkinnedHTML($view->render());
$mail->Send();
}
// TODO: add history of approval
Notification::confirm('Revision is now live');
Url::redirect($url);
} else {
Pdb::update('page_revisions', ['status' => 'rejected'], ['id' => $rev_id]);
$url = Subsites::getAbsRoot($rev['subsite_id']) . Page::url($rev['page_id']);
// Send an email to the operator who requested the change
if ($rev['op_id'] > 0) {
$view = new View('sprout/email/page_rejected');
$view->page_name = $rev['page_name'];
$view->url = $url;
$view->message = @$_POST['message'];
$mail = new Email();
$mail->AddAddress($rev['email']);
$mail->Subject = 'Page change rejected on ' . Kohana::config('sprout.site_title');
$mail->SkinnedHTML($view->render());
$mail->Send();
}
// TODO: add history of denial
Notification::confirm('Revision has been rejected');
Url::redirect($url);
}
}
}