<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program 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 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\controllers;
use seekquarry\atto as A;
use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\AnalyticsManager;
use seekquarry\yioop\library\UrlParser;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\controllers\components\Component;
use seekquarry\yioop\views\View;
/**
* Load crawlHash and timing functions
*/
require_once C\BASE_DIR . "/library/Utility.php";
/** For guessing locale and formatting date based on locale guessed*/
require_once C\BASE_DIR . "/library/LocaleFunctions.php";
/**
* Translate the supplied arguments into the current locale.
*
* This function is a convenience copy of the same function
* @see seekquarry\yioop\library\tl() to this subnamespace
*
* @param string string_identifier identifier to be translated
* @param mixed additional_args used for interpolation in translated string
* @return string translated string
*/
function tl()
{
return call_user_func_array(C\NS_LIB . "tl", func_get_args());
}
/**
* shorthand for echo
*
* @param string $text string to send to the current output
*/
function e($text)
{
echo $text;
}
/**
* Base controller class for all controllers on
* the SeekQuarry site.
*
* @author Chris Pollett
*/
abstract class Controller
{
/**
* How long, in seconds, a proof-of-work challenge stays valid before
* the browser's answer is considered stale.
*/
const HASH_TIMESTAMP_TIMEOUT = 300;
/**
* How many leading zeroes the proof-of-work hash must have. A larger
* number makes the browser work harder before it can submit a form.
*/
const HASH_CAPTCHA_LEVEL = 2;
/**
* Stores a reference to the web server when Yioop runs in CLI mode,
* it acts as request router in non-CLI mode.
* In CLI, mode it is useful for caching files in RAM as they are read
* @var seekquarry\atto\WebSite
*/
public $web_site;
/**
* Array of instances of components used by this controller
* @var array
*/
public $component_instances;
/**
* Array of instances of views used by this controller
* @var array
*/
public $view_instances = [];
/**
* Array of instances of models used by this controller
* @var array
*/
public $model_instances;
/**
* Array of instances of indexing_plugins used by this controller
* @var array
*/
public $plugin_instances;
/**
* Says which activities (roughly methods invoke from the web) this
* controller will respond to
* @var array
*/
public $activities = [];
/**
* Associative array of activity => component activity is on, used
* by @see Controller::call method to actually invoke a given activity
* on a given component
* @var array
*/
public $activity_component = [];
/**
* Associative array of $components activities for this controller
* Components are collections of activities (a little like traits) which
* can be reused.
*
* @var array
*/
public static $component_activities = [];
/**
* Sets up component activities, instance array, and plugins.
*
* @param seekquarry\atto\WebSite $web_site is the web server
* when Yioop runs in CLI mode, it acts as request router in non-CLI
* mode. In CLI, mode it is useful for caching files in RAM as they
* are read
* @return mixed redirectWithMessage call result on a "signout"
* request (under CLI it returns the request data array);
* void on the normal path
*/
public function __construct($web_site = null)
{
$this->web_site = $web_site; /* for redirects to work this needs to
be initialized here */
$this->component_instances = [];
$this->plugin_instances = [];
$this->model_instances = [];
$this->view_instances = [];
if (!empty($_SESSION['USER_ID'])) {
if ($this->model('user')->getUserStatus($_SESSION['USER_ID']) ===
false) {
$_REQUEST['a'] = "signout";
}
}
// this is to force a signout request to signout in all controllers
if (isset($_REQUEST['a']) && $_REQUEST['a'] == "signout") {
if (!empty($_COOKIE[C\p('SESSION_NAME')])) {
$this->model("user")->deleteSessionUser(
$_COOKIE[C\p('SESSION_NAME')]);
}
$_SESSION = [];
unset($_REQUEST);
return $this->redirectWithMessage(
tl('search_controller_logout_successful'));
}
/* if no signout was made, get on with the business of building
this controller
*/
mb_internal_encoding("UTF-8");
mb_regex_encoding("UTF-8");
$class = get_class($this);
foreach ($class::$component_activities as $component => $activities) {
foreach ($activities as $activity) {
$this->activity_component[$activity] = $component;
$this->activities[] = $activity;
}
}
}
/**
* This function should be overridden to web handle requests
*/
public abstract function processRequest();
/**
* Dynamic loader for Component objects which might live on the current
* Component
*
* @param string $component name of model to return
* @return Component the singleton instance for this controller of
* the given component, instantiated lazily on first request
*/
public function component($component)
{
if (!isset($this->component_instances[$component])) {
$component_name = C\NS_COMPONENTS . ucfirst($component) .
"Component";
$this->component_instances[$component] =
new $component_name($this);
}
return $this->component_instances[$component];
}
/**
* Dynamic loader for Model objects which might live on the current
* Controller
*
* @param string $model name of model to return
* @return Model the singleton instance for this controller of the
* given model, instantiated lazily on first request
*/
public function model($model)
{
if (!isset($this->model_instances[$model])) {
$model_name = C\NS_MODELS . ucfirst($model) . "Model";
$this->model_instances[$model] = new $model_name(C\p('DB_NAME'),
true, $this->web_site);
}
return $this->model_instances[$model];
}
/**
* Dynamic loader for Plugin objects which might live on the current
* Controller
*
* @param string $plugin name of Plugin to return
* @return object the singleton instance for this controller of the
* given plugin, instantiated lazily on first request
*/
public function plugin($plugin)
{
if (!isset($this->plugin_instances[$plugin])) {
$plugin_name = C\NS_PLUGINS .
ucfirst($plugin)."Plugin";
$this->plugin_instances[$plugin] = new $plugin_name();
}
return $this->plugin_instances[$plugin];
}
/**
* Used to get a list of all available indexing plugins for this Yioop
* instance.
*
* @return array names of indexing plugins discovered under
* BASE_DIR/library/indexing_plugins/ and APP_DIR/library/
* indexing_plugins/
*/
public function getIndexingPluginList()
{
$dir_prefixes = [C\BASE_DIR, C\APP_DIR];
foreach ($dir_prefixes as $dir_prefix) {
$plugin_dir = $dir_prefix . "/library/indexing_plugins/";
$plugin_dir_len = strlen($plugin_dir);
$plugin_ext_len = strlen("Plugin.php");
foreach (glob("$plugin_dir*Plugin.php") as $filename) {
$tmp_plug_name = substr($filename, $plugin_dir_len,
-$plugin_ext_len);
if ($tmp_plug_name != "Indexing") {
$indexing_plugins[] = $tmp_plug_name;
}
}
}
return $indexing_plugins;
}
/**
* Dynamic loader for View objects which might live on the current
* Controller
*
* @param string $view name of view to return
* @return View the singleton instance for this controller of the
* given view, instantiated lazily on first request
*/
public function view($view)
{
if (!isset($this->view_instances[$view])) {
$view_name = C\NS_VIEWS . ucfirst($view)."View";
$this->view_instances[$view] = new $view_name($this);
}
return $this->view_instances[$view];
}
/**
* Send the provided view to output, drawing it with the given
* data variable, using the current locale for translation, and
* writing mode
*
* @param string $view the name of the view to draw
* @param array $data an array of values to use in drawing the view
*/
public function displayView($view, $data)
{
$data['LOCALE_TAG'] = L\getLocaleTag();
$data['LOCALE_DIR'] = L\getLocaleDirection();
$data['WRITING_MODE'] = L\getWritingMode();
if (C\QUERY_STATISTICS) {
$data['QUERY_STATISTICS'] = [];
$machine = isset($_SERVER["HTTP_HOST"]) ?
htmlentities($_SERVER["HTTP_HOST"]) : "localhost";
$machine_uri = isset($_SERVER['REQUEST_URI']) ?
htmlentities($_SERVER['REQUEST_URI']) : "/";
$protocol = (isset($_SERVER["HTTPS"])) ? "https://" : "http://";
if ($machine == '::1') { //IPv6 :(
$machine = "[::1]/";
//used if the fetching and queue serving on the same machine
}
$data['YIOOP_INSTANCE'] = $protocol . $machine . $machine_uri;
$data['TOTAL_ELAPSED_TIME'] = 0;
foreach ($this->model_instances as $model_name => $model) {
$data['QUERY_STATISTICS'] = array_merge(
$model->db->query_log,
$data['QUERY_STATISTICS']
);
$data['TOTAL_ELAPSED_TIME'] +=
$model->db->total_time;
}
$locale_info = L\getLocaleQueryStatistics();
$data['QUERY_STATISTICS'] = array_merge(
$locale_info['QUERY_LOG'],
$data['QUERY_STATISTICS']
);
$data['TOTAL_ELAPSED_TIME'] +=
$locale_info['TOTAL_ELAPSED_TIME'];
$mail_total_time = AnalyticsManager::get("MAIL_TOTAL_TIME");
$mail_messages = AnalyticsManager::get("MAIL_MESSAGES");
if ($mail_total_time && $mail_messages) {
$data['QUERY_STATISTICS'] = array_merge($mail_messages,
$data['QUERY_STATISTICS']
);
$data['TOTAL_ELAPSED_TIME'] += $mail_total_time;
}
}
$data['c'] = isset($_REQUEST['c']) ? $_REQUEST['c'] : null;
$this->applyDomainAppearance($data);
if (isset($_SESSION['DISPLAY_MESSAGE'])) {
$data['DISPLAY_MESSAGE'] = $_SESSION['DISPLAY_MESSAGE'];
unset($_SESSION['DISPLAY_MESSAGE']);
}
$this->view($view)->render($data);
}
/**
* Returns the name the site goes by on the host this request came in
* on: the name a routed domain has been given when it has one, and
* the site's own name otherwise. A page's title and the label on its
* logo say who the site is, and a site answering on several domains
* is a different who on each of them, so asking the host rather than
* naming the site once is what lets one Yioop be several sites.
*
* @return string name to call the site on this request's host
*/
public function siteName()
{
$host = $this->requestHost();
if ($host !== '') {
$appearance =
$this->model('group')->getDomainAppearance($host);
if (!empty($appearance['SITE_NAME'])) {
return $appearance['SITE_NAME'];
}
}
return C\p('SITE_NAME');
}
/**
* Returns the lowercased request host with any port suffix
* removed, matching the bare host names stored as custom-route
* domains. An IPv6 literal's bracketed colons are left in place.
* Returns the empty string when no host is available.
*
* @return string normalized request host, or "" when none
*/
public function requestHost()
{
$host = strtolower($_SERVER['HTTP_HOST'] ??
($_SERVER['SERVER_NAME'] ?? ''));
$colon = strrpos($host, ':');
if ($colon !== false && strpos($host, ']') === false) {
$host = substr($host, 0, $colon);
}
return $host;
}
/**
* Overlays the current request domain's saved appearance fields
* (colors, logos, favicon, background image, auxiliary CSS,
* searchbar path) onto $data so the layout renders
* that domain's look. A domain with no saved appearance is left
* untouched and keeps the global Appearance settings, as does
* every request when no custom domain routes exist. The values
* land in the same $data keys the layout already reads, so the
* existing "use $data value when set, else the global constant"
* logic applies them with no further view changes.
*
* @param array &$data field data being sent to the view, modified
* in place with the domain's appearance overrides
*/
public function applyDomainAppearance(&$data)
{
$host = $this->requestHost();
if ($host === '') {
return;
}
$appearance =
$this->model('group')->getDomainAppearance($host);
if (empty($appearance)) {
return;
}
foreach ($appearance as $field => $value) {
if ($field === 'LANDING_PAGE') {
/* landing behavior is global, or per-domain through
the domain's route; never overlaid from a saved
appearance row even if an older one still holds it */
continue;
}
if ($field === 'SEARCHBAR_PATH') {
/* the search toolbar names the site once for a whole
browser, so it is set for the site on Page Options and
never overlaid from a saved appearance row, which may
still hold one from when it was set per domain */
continue;
}
if ($value !== '' && $value !== null) {
$data[$field] = $value;
}
}
if (!empty($appearance['AUXILIARY_CSS_NAME'])) {
/* Name the theme this page is dressed in separately from the
field the appearance form fills in, which may be showing
the theme of whichever scope is being customized rather
than of the host being browsed. Which theme a domain wears
is the domain's own, but the theme itself is one of the
site's, so there is nowhere else to read it from. */
$data['PAGE_THEME_NAME'] = $appearance['AUXILIARY_CSS_NAME'];
}
}
/**
* Does a 301 redirect to the given location, sets a session variable
* to display a message when get there.
*
* @param string $message message to write
* @param string $copy_fields $_REQUEST fields to copy for redirect
* @param bool $restart if yioop is being run as its own server rather than
* under apache whether to restart this server.
* @param bool $use_base_url set true if the base_url should
* be included in the redirect
* @return mixed under CLI without an own-web-server attached, returns
* the original $_REQUEST data array (used by configure_tool); in
* every normal HTTP path the function calls header()/exit and
* does not return
*/
public function redirectWithMessage($message, $copy_fields = false,
$restart = false, $use_base_url = false)
{
$default_fields = ["c", "a", C\p('CSRF_TOKEN'), "just_thread",
"just_group_id", "just_user_id", "group_id", "user_id", "role_id",
"limit", "num", "v"];
if ($copy_fields) {
$copy_fields = array_merge($default_fields, $copy_fields);
} else {
$copy_fields = $default_fields;
}
if (!empty($_REQUEST['context']) && !in_array("context", $copy_fields)){
switch ($_REQUEST['context']) {
case "account":
$use_base_url = true;
unset($_REQUEST['route']['c']);
$_REQUEST['c'] = "admin";
$_REQUEST['a'] = "manageAccount";
unset($_REQUEST['context']);
break;
case "groups":
$use_base_url = true;
unset($_REQUEST['route']['c']);
$_REQUEST['c'] = "group";
$_REQUEST['a'] = "groupFeed";
unset($_REQUEST['context']);
break;
case "join_groups":
$use_base_url = true;
unset($_REQUEST['route']['c']);
$_REQUEST['c'] = "admin";
$_REQUEST['a'] = "manageGroups";
$_REQUEST['browse'] = "true";
$copy_fields[] = "browse";
unset($_REQUEST['context']);
break;
}
}
$query_array = [];
foreach ($copy_fields as $field) {
if (isset($_REQUEST[$field])) {
if (is_array($_REQUEST[$field])) {
$array_params_cleaned = $_REQUEST[$field];
foreach ($array_params_cleaned as $key => $value) {
$query_array[$field][$this->clean($key, "string")] =
$this->clean($value, "string") ;
}
} else {
$query_array[$field] = $this->clean($_REQUEST[$field],
"string");
}
}
}
if (isset($_REQUEST['route'])) {
foreach ($query_array as $field => $value) {
if (!empty($_REQUEST['route'][$field])) {
unset($query_array[$field]);
}
}
}
$query_array = array_filter($query_array);
$url = ($use_base_url) ? C\baseUrl() : "";
/* the query-less fallback (mainly signout, which empties
$_REQUEST) is host-relative so that signing out on a
domain with its own landing route stays on that domain;
on the base host it resolves to the same address as
before
*/
$location = ($query_array == []) ? C\SHORT_BASE_URL :
$url . "?" . http_build_query($query_array);
if ($message) {
$data['MESSAGE'] = $message;
$_SESSION['DISPLAY_MESSAGE'] = $message;
} else {
unset($_SESSION['DISPLAY_MESSAGE']);
}
if (empty($this->web_site) && php_sapi_name() == 'cli') {
//this case happens for configure_tool.php
return $data;
}
if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
$_SERVER['HTTP_X_REQUESTED_WITH'] == "XMLHttpRequest") {
e("go$location");
} else {
if (!empty($this->web_site)) {
if ($restart && C\nsdefined("IS_OWN_WEB_SERVER")) {
$this->saveOwnServerDaemonState();
$sessions = $this->web_site->getSessions();
if (!empty($sessions["TERMINAL"])) {
e(tl("controller_manual_restart_required"));
A\webExit("stop");
}
/* Point the poll at the admin configure status
route. controllerUrl gives the right form
whether the site uses pretty URLs or query
strings, and the right separator, so this works
in the first-configuration case where the
redirect target itself is just the site root
with no query string to append to. */
$status_location = B\controllerUrl("admin", true) .
"a=configure&arg=restart_status";
$this->web_site->header("Connection:close");
e($this->restartStatusPage($location,
$status_location));
A\webExit("restart " . C\DATA_DIR .
"/PreviousSession.txt");
} else {
$this->web_site->header("Location:$location");
}
}
}
$_FILES = []; // this is for when Yioop run as own web server
A\webExit();
}
/**
* Records and cleanly stops the name-server daemons
* (MediaUpdater, MailServer) that were started in the
* background through Manage Machines, ahead of an own-web-
* server restart, so the replacement process can relaunch them
* from the marker file it reads on boot. Daemons a user
* launched in the foreground with `php Name.php terminal` are
* left running and are not recorded: their own terminal owns
* their lifetime, not the web server. A daemon that is not
* running, or whose origin is unknown, is left alone. Only runs
* when Yioop is its own web server.
*
* @return void
*/
public function saveOwnServerDaemonState()
{
L\CrawlDaemon::saveDaemonStateForRestart();
}
/**
* Builds the page shown after a Restart Server action. The page
* polls the restart-status route every few seconds and reports
* the current phase. Fetches fail during the brief window when the
* server is itself restarting; the script keeps trying, then sends
* the browser back to $location once the status reports the
* restart is finished.
*
* @param string $location url to return to when the restart is
* done
* @param string $status_location url of the restart-status route
* that returns the current phase as JSON
* @return string the html of the restart progress page
*/
public function restartStatusPage($location, $status_location)
{
$location = html_entity_decode($location);
$waiting = tl("controller_restart_server");
$poll = C\RESTART_STATUS_POLL_TIME;
$page = "<!DOCTYPE html><html><head><meta charset='utf-8'>".
"<title>" . $waiting . "</title></head><body>".
"<p id='restart-message'>" . $waiting . "</p><script>".
"var status_url = " . json_encode($status_location) . ";".
"var done_url = " . json_encode($location) . ";".
"var poll_time = " . intval($poll) . ";".
"var tries = 0;".
"var max_tries = 120;".
"function setMessage(text) {".
" var node = document.getElementById('restart-message');".
" if (node) { node.textContent = text; }".
"}".
"function checkRestart() {".
" tries++;".
" if (tries > max_tries) {".
" window.location = done_url;".
" return;".
" }".
" fetch(status_url, {credentials: 'same-origin'}).then(".
" function (response) { return response.json(); }).then(".
" function (info) {".
" if (!info.phase) {".
" window.location = done_url;".
" return;".
" }".
" setMessage(info.phase);".
" }).catch(function () {});".
"}".
"setInterval(checkRestart, poll_time);".
"checkRestart();".
"</script></body></html>";
return $page;
}
/**
* Method to perform a 301 redirect to $location in both under web server
* and CLI setting
*
* @param string $location url to redirect to
*/
public function redirectLocation($location)
{
$this->web_site->header("Location:$location");
$_FILES = []; // this is for when Yioop run as own web server
A\webExit();
}
/**
* When an activity involves displaying tabular data (such as rows of
* users, groups, etc), this method might be called to set up $data
* fields for next, prev, and page links, it also makes the call to the
* model to get the row data sorted and restricted as desired. For some
* data sources, rather than directly make a call to the model to get the
* data it might be passed directly to this method.
*
* @param array &$data used to send data to the view will be updated by
* this method with row and paging data
* @param mixed $field_or_model if an object, this is assumed to be a model
* and so the getRows method of this model is called to get row data,
* sorted and restricted according to $search_array; if a string
* then the row data is assumed to be in $data[$field_or_model] and
* pagingLogic itself does the sorting and restricting.
* @param string $output_field output rows for the view will be stored in
* $data[$output_field]
* @param int $default_show if not specified by $_REQUEST, then this will
* be used to determine the maximum number of rows that will be
* written to $data[$output_field]
* @param array $search_array used to sort and restrict in
* the getRows call or the data from $data[$field_or_model].
* Each element of this is a quadruple name of a field, what comparison
* to perform, a value to check, and an order (ascending/descending)
* to sort by
* @param string $var_prefix if there are multiple uses of pagingLogic
* presented on the same view then $var_prefix can be prepended to
* to the $data field variables like num_show, start_row, end_row
* to distinguish between them
* @param array $args additional arguments that are passed to getRows and
* in turn to selectCallback, fromCallback, and whereCallback that
* might provide user_id, etc to further control which rows are
* returned
*/
public function pagingLogic(&$data, $field_or_model, $output_field,
$default_show, $search_array = [], $var_prefix = "", $args = null)
{
$data_fields = [];
$r = [];
$request_fields = ['num_show' => $default_show,
'start_row' => 0, 'end_row' => $default_show];
foreach ($request_fields as $field => $default) {
if (isset($_REQUEST[$var_prefix . $field])) {
$r[$field] = $_REQUEST[$var_prefix . $field];
} else {
$r[$field] = $default;
}
}
if ($r['start_row'] + $r['num_show'] != $r['end_row']) {
$r['end_row'] = $r['start_row'] + $r['num_show'];
}
$d = [];
$data_fields = ['NUM_TOTAL', 'NUM_SHOW', 'START_ROW', 'END_ROW',
'NEXT_START', 'NEXT_END', 'PREV_START', 'PREV_END'];
$var_field = strtoupper($var_prefix);
foreach ($data_fields as $field) {
$d[$field] = $var_prefix . $field;
}
$num_show = (isset($r['num_show']) &&
isset($this->view("admin")->helper("pagingtable")->show_choices[
$r['num_show']])) ? $r['num_show'] : $default_show;
$data[$d['NUM_SHOW']] = $num_show;
$data[$d['START_ROW']] = isset($r['start_row']) ?
max(0, $this->clean($r['start_row'],"int")) : 0;
if (is_object($field_or_model)) {
$data[$output_field] = $field_or_model->getRows(
$data[$d['START_ROW']], $num_show, $num_rows, $search_array,
$args);
} else {
$num_rows = count($data[$field_or_model]);
if ($search_array != []) {
$out_data = [];
foreach ($data[$field_or_model] as $name => $field_data) {
$checks_passed = true;
foreach ($search_array as $search_data) {
list($column_name, $comparison, $search_value, $sort) =
$search_data;
if ($search_value === "") {
continue;
}
if (isset($args[$column_name])) {
$column_name = $args[$column_name];
}
$row_value = is_object($field_data) ?
$field_data->$column_name:
$field_data[$column_name];
$cmp = strcmp($search_value, $row_value);
if (($cmp == 0 && $comparison == "=") ||
($cmp != 0 && $comparison == "!=")
) {
continue;
}
$pos = strpos($row_value, $search_value);
$len_row = strlen($row_value);
$len_search = strlen($search_value);
if (($comparison == "CONTAINS" && $pos !== false) ||
($comparison == "BEGINS WITH" && $pos === 0) ||
($comparison == "ENDS WITH" && $pos === $len_row -
$len_search)) {
continue;
}
$checks_passed = false;
break;
}
if ($checks_passed) {
$out_data[$name] = $field_data;
}
}
foreach ($search_array as $search_data) {
list($column_name, $comparison, $search_value, $sort) =
$search_data;
if ($sort == "NONE") {
continue;
}
if (isset($args[$column_name])) {
$column_name = $args[$column_name];
}
$values = [];
foreach ($out_data as $name => $field_data) {
$values[$name] = is_object($field_data) ?
$field_data->$column_name:
$field_data[$column_name];
}
$sort = ($sort=="DESC") ? SORT_DESC: SORT_ASC;
array_multisort($values, $sort, $out_data);
}
} else {
$out_data = $data[$field_or_model];
}
$data[$output_field] = array_slice($out_data,
$data[$d['START_ROW']], $num_show);
}
$data[$d['START_ROW']] = min($data[$d['START_ROW']], $num_rows);
$data[$d['END_ROW']] = min($data[$d['START_ROW']] + $num_show,
$num_rows);
if (isset($r['start_row'])) {
$data[$d['END_ROW']] = max($data[$d['START_ROW']],
min($this->clean($r['end_row'],"int"), $num_rows));
}
$data[$d['NEXT_START']] = $data[$d['END_ROW']];
$data[$d['NEXT_END']] = min($data[$d['NEXT_START']] + $num_show,
$num_rows);
$data[$d['PREV_START']] = max(0, $data[$d['START_ROW']] - $num_show);
$data[$d['PREV_END']] = $data[$d['START_ROW']];
$data[$d['NUM_TOTAL']] = $num_rows;
}
/**
* Used to invoke an activity method of the current controller or one
* its components
*
* @param string $activity method to invoke
* @param string $modifiers access modifiers to executing this method
* @return mixed whatever the dispatched activity method returns:
* either a controller's own activity, or the activity on the
* registered Component for that activity
*/
public function call($activity, $modifiers = [])
{
if (isset($this->activity_component[$activity])) {
return $this->component(
$this->activity_component[$activity])->$activity($modifiers);
}
return $this->$activity();
}
/**
* Returns an array of the possible modifiers to the access to the activity
* in question.
*
* @param string $activity method to get access modifier list for
* @return array of string names => translated names of the access modifiers
* for the method in question (if any exist).
*/
public function getAccessModifiers($activity)
{
$modifiers_method = $activity . "Modifiers";
$activity_object = $this;
if (isset($this->activity_component[$activity])) {
$activity_object = $this->component(
$this->activity_component[$activity]);
}
if (method_exists($activity_object, $modifiers_method)) {
return $activity_object->$modifiers_method();
}
return [];
}
/**
* Generates a cross site request forgery preventing token based on the
* provided user name, the current time and the hidden AUTH_KEY
*
* @param string $user username to use to generate token
* @return string a csrf token
*/
public function generateCSRFToken($user)
{
$time = time();
$ip_address = L\remoteAddress();
/*remember _SESSION loaded from UserModel::getUserSession not just
cookie
*/
$_SESSION['OLD_CSRF_TIME'] = (empty($_SESSION['OLD_CSRF_TIME']) ||
!is_array($_SESSION['OLD_CSRF_TIME'])) ? [] :
$_SESSION['OLD_CSRF_TIME']; // create empty array if not set
if (count($_SESSION['OLD_CSRF_TIME']) > C\MAX_DEVICES) {
$_SESSION['OLD_CSRF_TIME'] = array_slice($_SESSION['OLD_CSRF_TIME'],
-C\MAX_DEVICES);
}
$_SESSION['OLD_CSRF_TIME'][$ip_address] =
(isset($_SESSION['CSRF_TIME'][$ip_address])) ?
$_SESSION['CSRF_TIME'][$ip_address] : 0;
$_SESSION['CSRF_TIME'] = (empty($_SESSION['CSRF_TIME']) ||
!is_array($_SESSION['CSRF_TIME'])) ? [] :
$_SESSION['CSRF_TIME']; // create empty array if not set
if (count($_SESSION['CSRF_TIME']) > C\MAX_DEVICES) {
$_SESSION['CSRF_TIME'] = array_slice($_SESSION['CSRF_TIME'],
-C\MAX_DEVICES);
}
$_SESSION['CSRF_TIME'][$ip_address] = $time;
return L\crawlAuthHash($user . $time)."*$time";
}
/**
* Checks if the form CSRF (cross-site request forgery preventing) token
* matches the given user and has not expired (1 hour till expires)
*
* @param string $token_name attribute of $_REQUEST containing CSRFToken
* @param string $user_id user id of the user to check the token for
* @param bool $use_name_as_passed whether to use $token_name as the
* token (if true) or to use $_REQUEST[$token_name]
* @return bool whether the CSRF token was valid
*/
public function checkCSRFToken($token_name, $user_id,
$use_name_as_passed = false)
{
$token_okay = false;
$token_value = ($use_name_as_passed) ? $token_name :
$_REQUEST[$token_name] ?? "";
/*
Token format is "<43-char crawlAuthHash>*<unix-time>".
43 + 1 + 10 = 54 chars; reject anything that can't be
this layout before reaching for hash_equals so that
malformed input doesn't reach the constant-time path
(the length itself is not a secret).
*/
if (strlen($token_value) == 54) {
$token_parts = explode("*", $token_value);
if (isset($token_parts[1]) &&
intval(trim($token_parts[1])) + C\p('AUTOLOGOUT') > time() &&
hash_equals(
L\crawlAuthHash($user_id . $token_parts[1]),
$token_parts[0])) {
$token_okay = true;
}
}
return $token_okay;
}
/**
* Checks if the timestamp in $_REQUEST[$token_name]
* matches the timestamp of the last CSRF token accessed by this user
* for the kind of activity for which there might be a conflict.
* This is to avoid accidental replays of postings etc if the back button
* used.
*
* @param string $token_name name of a $_REQUEST field used to hold a
* CSRF_TOKEN
* @param string $action name of current action to check for conflicts
* @return bool whether a conflicting action has occurred.
*/
public function checkCSRFTime($token_name, $action = "")
{
$token_okay = false;
if (isset($_REQUEST[$token_name])) {
$token_parts = explode("*", $_REQUEST[$token_name]);
if (isset($token_parts[1])) {
$timestamp_to_check = $token_parts[1];
$ip_address = L\remoteAddress();
/*remember _SESSION loaded from UserModel::getUserSession not
just cookie
*/
if ($action == "") {
if (isset($_SESSION['OLD_CSRF_TIME'][$ip_address]) &&
$token_parts[1] ==
$_SESSION['OLD_CSRF_TIME'][$ip_address]) {
$token_okay = true;
}
} else {
if (!isset($_SESSION['OLD_ACTION_STAMPS'][$action]) ||
(isset($_SESSION['OLD_ACTION_STAMPS'][$action]) &&
$_SESSION['OLD_ACTION_STAMPS'][$action] <=
$timestamp_to_check)) {
$_SESSION['OLD_ACTION_STAMPS'][$action] =
$timestamp_to_check;
$token_okay = true;
$cull_time = time() - C\p('AUTOLOGOUT');
foreach ($_SESSION['OLD_ACTION_STAMPS'] as $act =>
$time) {
if (intval($time) < $cull_time) {
unset($_SESSION['OLD_ACTION_STAMPS'][$act]);
}
}
}
}
}
}
return $token_okay;
}
/**
* Used to return just the timestamp portion of the CSRF token
* if it exists. 0 if the user has a non-expired session from this
* ip address, but no CSRF token, and -1 otherwise.
*
* @param string $token_name name of a $_REQUEST field used to hold a
* CSRF_TOKEN
* @return int the timestamp portion of the CSRF_TOKEN
*/
public function getCSRFTime($token_name)
{
$timestamp = -1;
$ip_address = L\remoteAddress();
if (isset($_REQUEST[$token_name])) {
$token_parts = explode("*", $_REQUEST[$token_name]);
if (isset($token_parts[1])) {
return intval($token_parts[1]);
}
} else if (!empty($_SESSION['OLD_CSRF_TIME'][$ip_address])) {
$old_time = $_SESSION['OLD_CSRF_TIME'][$ip_address];
if (time() - intval($old_time) < C\p('AUTOLOGOUT')) {
$timestamp = 0;
}
}
return $timestamp;
}
/**
* Prepares a wiki form's automatic proof-of-work check.
*
* The visitor's browser is asked to find a number (a nonce) that makes
* a hash come out with a required number of leading zeroes. This costs
* the browser a little work but is invisible to the person, so every
* wiki form is guarded against automated submissions without anyone
* having to type anything. A separate random word is also kept in the
* session; the wiki form code hashes it to tell a fresh submission
* apart from a reload of an already-saved row. The keyword-captcha flag
* is cleared here and only turned back on if the form also carries an
* author-supplied keyword captcha.
*
* @param array &$data used by the view to draw any dynamic content; the
* proof-of-work scripts are added to its INCLUDE_SCRIPTS list and the
* findNonce call that starts the browser working is appended to its
* SCRIPT, which the page foot runs after those scripts have loaded
*/
public function setupProofOfWorkViewData(&$data)
{
if (empty($_SESSION)) {
$_SESSION = [];
}
unset($_SESSION["captcha_text"]);
$_SESSION["captcha_keyword_required"] = false;
$characters_for_captcha = '123456789abcdefghijklmnpqrstuvwxyz'.
'ABCDEFGHIJKLMNPQRSTUVWXYZ';
$len = strlen($characters_for_captcha);
$word = "";
for ($i = 0; $i < C\CAPTCHA_LEN; $i++) {
$word = $word . $characters_for_captcha[rand(0, $len - 1)];
}
$_SESSION['captcha_text'] = $word;
$time = time();
$_SESSION["request_time"] = $time;
$_SESSION["level"] = self::HASH_CAPTCHA_LEVEL;
$_SESSION["random_string"] = md5($time . C\p('AUTH_KEY'));
if (empty($data['INCLUDE_SCRIPTS'])) {
$data['INCLUDE_SCRIPTS'] = [];
}
$data['INCLUDE_SCRIPTS'] = array_unique(array_merge(
$data['INCLUDE_SCRIPTS'], ["sha1", "hash_captcha"]));
/*
findNonce is defined in hash_captcha.js, which the page foot
loads. Adding the call to SCRIPT (also emitted at the foot,
after the includes) rather than inline in the form body makes
sure the function exists by the time it runs.
*/
if (empty($data['SCRIPT'])) {
$data['SCRIPT'] = "";
}
$data['SCRIPT'] .= "findNonce('nonce_for_string', " .
"'random_string', 'time', 'level');";
}
/**
* Checks whether a browser solved a proof-of-work challenge.
*
* It rebuilds the string the browser was asked to hash (the server's
* random string, the time it was issued, and the browser's chosen
* nonce) and confirms the hash starts with the required number of
* zeroes. Pure check with no side effects so any controller or
* component can call it.
*
* @param string $random_string the random value the server handed out
* @param string $request_time when the challenge was issued
* @param string $nonce the number the browser found
* @param int $level how many leading zeroes the hash must have
* @return bool true when the hash has enough leading zeroes
*/
public function meetsProofOfWork($random_string, $request_time, $nonce,
$level)
{
$pattern = '/^0{' . intval($level) . '}/';
return (bool)preg_match($pattern,
sha1($random_string . ':' . $request_time . ':' . $nonce));
}
/**
* Used to clean strings that might be tainted as originate from the user
*
* @param mixed $value tainted data
* @param mixed $type type of data in value can be one of the following
* strings: bool, color, double, float, int, hash, or string, web-url;
* or it can be an array listing allowed values. If the latter, then
* if the value is not in the array the cleaned value will be first
* element of the array if $default is null
* @param mixed $default if $value is not set default value is returned,
* this isn't used much since if the error_reporting is E_ALL
* or -1 you would still get a Notice.
* @return string the clean input matching the type provided
*/
public function clean($value, $type, $default = null)
{
$clean_value = null;
if (is_array($type)) {
if (in_array($value, $type)) {
return $value;
} else {
if (isset($default)) {
return $default;
}
reset($type);
return current($type);
}
}
switch ($type) {
case "boolean":
// no break
case "bool":
if (isset($value)) {
if (!is_bool($value)) {
$clean_value = false;
if ($value == "true" || $value != 0) {
$clean_value = true;
}
}
} else if (isset($default)) {
$clean_value = $default;
} else {
$clean_value = false;
}
break;
case "color":
if (isset($value)) {
$colors = ["black", "silver", "gray", "white",
"maroon", "red", "purple", "fuchsia", "green", "lime",
"olive", "yellow", "navy", "blue", "teal", "aqua",
"orange", "aliceblue", "antiquewhite", "aquamarine",
"azure", "beige", "bisque", "blanchedalmond",
"blueviolet", "brown", "burlywood", "cadetblue",
"chartreuse", "chocolate", "coral", "cornflowerblue",
"cornsilk", "crimson", "darkblue", "darkcyan",
"darkgoldenrod", "darkgray", "darkgreen", "darkgrey",
"darkkhaki", "darkmagenta", "darkolivegreen",
"darkorange", "darkorchid", "darkred", "darksalmon",
"darkseagreen", "darkslateblue", "darkslategray",
"darkslategrey", "darkturquoise", "darkviolet",
"deeppink", "deepskyblue", "dimgray", "dodgerblue",
"firebrick", "floralwhite", "forestgreen", "gainsboro",
"ghostwhite", "gold", "goldenrod", "greenyellow",
"grey", "honeydew", "hotpink", "indianred", "indigo",
"ivory", "khaki", "lavender", "lavenderblush",
"lawngreen", "lemonchiffon", "lightblue", "lightcoral",
"lightcyan", "lightgoldenrodyellow", "lightgray",
"lightgreen", "lightgrey", "lightpink", "lightsalmon",
"lightseagreen", "lightskyblue", "lightslategray",
"lightslategrey", "lightsteelblue", "lightyellow",
"limegreen", "linen", "mediumaquamarine",
"mediumblue", "mediumorchid", "mediumpurple",
"mediumseagreen", "mediumslateblue",
"mediumspringgreen", "mediumturquoise",
"mediumvioletred", "midnightblue", "mintcream",
"mistyrose", "moccasin", "navajowhite", "oldlace",
"olivedrab", "orangered", "orchid", "palegoldenrod",
"palegreen", "paleturquoise", "palevioletred",
"papayawhip", "peachpuff", "peru", "pink", "plum",
"powderblue", "rosybrown", "royalblue", "saddlebrown",
"salmon", "sandybrown", "seagreen", "seashell",
"sienna", "skyblue", "slateblue", "slategray",
"slategrey", "snow", "springgreen", "steelblue",
"tan", "thistle", "tomato", "turquoise", "violet",
"wheat", "whitesmoke", "yellowgreen", "rebeccapurple"
];
if (in_array($value, $colors)
|| preg_match('/^#[a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]'.
'([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9])?$/',
trim($value))) {
$clean_value = trim($value);
} else {
$clean_value = "#FFF";
}
} else if (isset($default)) {
$clean_value = $default;
} else {
$clean_value = "#FFF";
}
break;
case "double":
if (isset($value)) {
$clean_value = doubleval($value);
} else if (isset($default)) {
$clean_value = $default;
} else {
$clean_value = 0;
}
break;
case "float":
if (isset($value)) {
$clean_value = floatval($value);
} else if (isset($default)) {
$clean_value = $default;
} else {
$clean_value = 0;
}
break;
case "file_name":
if (isset($value)) {
$clean_value = strtr($value, ["&" => "&", "\\" => "/",
"*" => "-", ":" => "-", ".." => "-", '"' => '-',
"'" => '-']);
} else {
$clean_value = $default;
}
break;
case "hash":
if (isset($value)) {
if (strlen($value) == strlen(L\crawlHash("A")) &&
base64_decode($value)) {
$clean_value = $value;
}
} else {
$clean_value = $default;
}
break;
case "imap_arg":
/* a string about to be embedded in an IMAP command
argument. ASCII control characters (CR and LF in
particular) would let an attacker smuggle a CRLF
into the wire payload and split it into an extra
IMAP command, so they are stripped here. The
length is clamped to 256 characters since folder
names and filter terms have no legitimate need
to be longer. Quoting of backslashes and double-
quotes stays in userMailImapQuote, since that is
IMAP's syntactic quoted-string encoding rather
than input sanitization. */
if (isset($value)) {
$clean_value = preg_replace('/[\x00-\x1F\x7F]/',
'', (string) $value);
if (strlen($clean_value) > 256) {
$clean_value = substr($clean_value, 0, 256);
}
} else if (isset($default)) {
$clean_value = $default;
} else {
$clean_value = "";
}
break;
case "int":
if (isset($value)) {
$clean_value = intval($value);
} else if (isset($default)) {
$clean_value = $default;
} else {
$clean_value = 0;
}
break;
case "int32":
if (isset($value)) {
$clean_value = intval($value);
if ($clean_value < -2147483648) {
$clean_value = -2147483648;
} else if ($clean_value > 2147483647) {
$clean_value = 2147483647;
}
} else if (isset($default)) {
$clean_value = $default;
} else {
$clean_value = 0;
}
break;
case "password":
if (isset($value)) {
$clean_value = $value;
/* password is always hashed in code
before used/stored (make sure to always double-check)*/
} else {
$clean_value = $default;
}
break;
case "string":
if (isset($value)) {
$value2 = str_replace("&", "&", $value);
// -CP REMEMBER TO CK THIS!!!!!
$value2 = mb_convert_encoding($value2, "UTF-8");
$clean_value = $value2;
$clean_value = @htmlspecialchars($value2, ENT_QUOTES,
"UTF-8");
} else {
$clean_value = $default;
}
break;
case "time":
if (isset($value)) {
$value = trim($value);
$value_parts = explode(":", $value);
if (isset($value_parts[1])) {
$hour = intval($value_parts[0]);
$min = intval($value_parts[1]);
if ($hour >= 0 && $hour < 24 && $min >= 0 &&
$min < 60) {
$clean_value = $value;
} else {
$clean_value = "00:00";
}
} else {
$clean_value = "00:00";
}
} else if (isset($default)) {
$clean_value = $default;
} else {
$clean_value = "00:00";
}
break;
case 'web-url':
if (isset($value)) {
$value = trim($value);
$start = substr($value, 0, 4);
$is_web = ($start == 'http');
if (!$is_web && $start != "goph") {
$value = "http://$value";
$is_web = true;
}
if ($is_web) {
$value = str_replace("&", "&", $value);
$clean_value = @htmlentities($value, ENT_QUOTES,
"UTF-8");
}
$clean_value = UrlParser::canonicalLink($clean_value,
$default, false);
} else {
$clean_value = $default;
}
break;
}
return $clean_value;
}
/**
* Converts an array of lines of strings into a single string with
* proper newlines, each line having been trimmed and potentially
* cleaned
*
* @param array $arr the array of lines to be process
* @param string $endline_string what string should be used to indicate
* the end of a line
* @param bool $clean whether to clean each line
* @return string a concatenated string of cleaned lines
*/
public function convertArrayLines($arr, $endline_string="\n",
$clean = false)
{
$output = "";
$eol = "";
foreach ($arr as $line) {
$output .= $eol;
$out_line = trim($line);
if ($clean) {
$out_line = $this->clean($out_line, "string");
}
$output .= trim($out_line);
$eol = $endline_string;
}
return $output;
}
/**
* Cleans a string consisting of lines, typically of urls into an array of
* clean lines. This is used in handling data from the crawl options
* text areas. # is treated as a comment
*
* @param string $str contains the url data
* @param string $line_type does additional cleaning depending on the type
* of the lines. For instance, if is "url" then a line not beginning
* with a url scheme will have http:// prepended.
* @return array $lines an array of clean lines
*/
public function convertStringCleanArray($str, $line_type="url")
{
$pre_lines = preg_split('/\n+/', $str);
$lines = [];
foreach ($pre_lines as $line) {
$pre_line = trim($this->clean($line, "string"));
if (strlen($pre_line) > 0) {
if ($line_type == "url") {
$start_line = substr($pre_line, 0, 6);
if (!in_array($start_line,
["file:/", "http:/", "domain", "https:", 'gopher',
"regex:"]) &&
$start_line[0] != "#") {
$pre_line = "https://". $pre_line;
}
}
$lines[] = $pre_line;
}
}
return $lines;
}
/**
* Checks the request if a request is for a valid activity and if it uses
* the correct authorization key
*
* @return bool whether the request was valid or not
*/
public function checkRequest()
{
if (empty($_REQUEST['time']) ||
empty($_REQUEST['session']) || (
!in_array($_REQUEST['a'], $this->activities)
&& $_REQUEST['c'] != 'jobs' )) {
return false;
}
$time = intval($_REQUEST['time']);
// request must be within the autologout of this machine's clock
if (abs(time() - $time) > intval(C\p('AUTOLOGOUT'))) {
return false;
}
$session = $_REQUEST['session'];
if (md5($time . C\p('AUTH_KEY')) != $session) {
return false;
}
return true;
}
/**
* Used to set up the head variables for and page_data of a wiki or static
* page associated with a view.
*
* @param object $view View on which page data will be rendered
* @param string $page_id an id (or symbolic key) identifying the page,
* used to file its parsed head and body under; for example a numeric
* wiki page id, or 404 for the static 404 error page
* @param string $page_data this is the actual content of a wiki or
* static page
*/
public function parsePageHeadVarsView($view, $page_id, $page_data)
{
list($view->head_objects[$page_id], $view->page_objects[$page_id]) =
L\WikiParser::parsePageHeadVars($page_data, true);
}
/**
* If external source advertisements are present in the output of this
* controller this function can be used to initialize the field variables
* used to write the appropriate Javascripts
*
* @param array &$data data to be used in drawing the view
* @param bool $ads_off whether or not ads are turned off so that this
* method should do nothing
*/
public function initializeAdFields(&$data, $ads_off = false)
{
if (C\p('AD_LOCATION') != "none") {
$data["AD_LOCATION"] = ($ads_off) ? "none" : C\p('AD_LOCATION');
$ad_fields = ['TOP_ADSCRIPT', 'SIDE_ADSCRIPT', 'GLOBAL_ADSCRIPT'];
foreach ($ad_fields as $ad_field) {
$ad = html_entity_decode(constant(C\NS_CONFIGS . $ad_field),
ENT_QUOTES);
$ad = preg_replace("[(]","(",$ad);
$data[$ad_field] = preg_replace("[)]",")",$ad);
}
}
}
/**
* Adds to an integer, $actual_value, epsilon-noise taken from an
* L_1 gaussian source to centered at $actual_value to get a epsilon
* private, integer value.
*
* @param int $actual_value number want to make private
* @return int $fuzzy_value number after noise added
*/
public function addDifferentialPrivacy($actual_value)
{
$sigma = 1 / C\PRIVACY_EPSILON;
$max_value = (2 * $actual_value) + 1;
/* Calculation by Integration
* f: exp(-1 * ((abs($actual_value - $x)) / $sigma))
* Since function consists of absolute value, break the integration
* into two to remove absolute from the function.
* First integral runs from 0 through $actual_value, and second
* integral runs from $actual_value through $max_value.
* Then after using substitution rule, first and second integral
* range change, say [a,b] and [b,c]
*/
$a = -1 * $actual_value;
$b = 0;
$c = -1 * ($max_value - $actual_value);
$integral_value = $sigma * (2 - exp($a / $sigma) - exp($c / $sigma));
$random = rand(0, $integral_value);
$p = 0;
for ($i = 0; $i < $max_value; $i++) {
$arg = -1 * (($actual_value - $i) / $sigma);
$p += exp($arg);
if ($p > $random) {
break;
}
}
$fuzzy_value = $i;
return $fuzzy_value;
}
/**
* Used to store in a session which media list items have been viewed so
* we can put an indicator by them when the media list is rendered
*
* @param int $page_id the id of page with media list
* @param string $sub_path the resource folder on that page
* @param string $media_name item to store indiicator into session for
*/
public function recordViewSession($page_id, $sub_path, $media_name)
{
$hash_id = L\crawlHash($page_id . $media_name . $sub_path);
if (empty($_SESSION['USER_ID'])||
!L\isPositiveInteger($_SESSION['USER_ID']) ||
$_SESSION['USER_ID'] == C\PUBLIC_USER_ID || !empty($_REQUEST['t'])){
return;
}
if (empty($_SESSION['seen_media']) ||
!is_array($_SESSION['seen_media'])) {
$_SESSION['seen_media'] = [];
}
if (in_array($hash_id, $_SESSION['seen_media'])) {
if (end($_SESSION['seen_media']) == $hash_id) {
// this item was already the most recently viewed
return;
}
$_SESSION['seen_media'] = array_diff($_SESSION['seen_media'],
[$hash_id]);
}
array_push($_SESSION['seen_media'], $hash_id);
$keep_deleting = true;
while ($keep_deleting) {
if (strlen(serialize($_SESSION)) >= C\MAX_USER_SESSION_SIZE) {
array_shift($_SESSION['seen_media']);
} else {
$keep_deleting = false;
}
if (count($_SESSION['seen_media']) <= 0) {
$keep_deleting = false;
}
}
$this->model("user")->setUserSession($_SESSION['USER_ID'], $_SESSION);
}
}