<?php namespace HashOver;

// Copyright (C) 2010-2021 Jacob Barkdull
// This file is part of HashOver.
//
// HashOver is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// HashOver 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with HashOver. If not, see <http://www.gnu.org/licenses/>.


class Setup extends Settings
{
public $hasIntl;
public $scheme;
public $absolutePath;
public $commentsPath;
public $threadsPath;
public $website;
public $isMobile = false;
public $remoteAccess = false;
public $filePath;
public $urlQueryList = array ();
public $threadQueryList = array ();
public $urlQueries;
public $threadQueries;
public $threadPath;
public $threadName = '';
public $pageURL = '';
public $pageTitle = '';
public $instanceNumber = 1;

// Required extensions to check for
protected $extensions = array (
'date', 'dom', 'json', 'mbstring', 'openssl', 'pcre'
);

// Characters to convert to dashes in thread names
protected $dashFromThreads = array (
'<', '>', ':', '"', '/', '\\', '|', '?',
'&', '!', '*', '.', '=', '_', '+', ' '
);

// Characters to convert to dashes in domain names
protected $dashFromDomains = array (
'<', '>', ':', '"', '/', '\\', '|', '?',
'&', '!', '*', '=', '_', '+', ' '
);

// HashOver-specific URL queries to be ignored
protected $hashoverQueries = array (
'hashover-reply', 'hashover-edit'
);

public function __construct ()
{
// Construct parent class
parent::__construct ();

// Check for required extensions
$this->extensionsLoaded ($this->extensions);

// Intl extension presence indicator
$this->hasIntl = extension_loaded ('intl');

// Get connection scheme
$this->scheme = $this->isHTTPS () ? 'https' : 'http';

// Absolute path or remote access
$this->absolutePath = $this->scheme . '://' . $this->domain;

// Check if multisite support is enabled
if ($this->supportsMultisites === true) {
// If so, set website based on domain
$this->setWebsite ($this->domain);
} else {
// If not, comments directory path
$this->commentsPath = $this->commentsRoot;

// Set threads directory path
$this->threadsPath = $this->commentsRoot . '/threads';

// And set website to "all"
$this->website = 'all';
}

// Check if we have a user agent
if (!empty ($_SERVER['HTTP_USER_AGENT'])) {
// If so, get user agent
$agent = $_SERVER['HTTP_USER_AGENT'];

// And check if visitor is on mobile device based on user agent
if (preg_match ('/android|blackberry|phone|mobile|tablet/i', $agent)) {
// If so, set mobile indicator to true
$this->isMobile = true;

// And set image format to vector
$this->imageFormat = 'svg';
}
}
}

// Throws an exception if a require extension isn't loaded
public function extensionsLoaded (array $extensions)
{
// Run through given extensions
foreach ($extensions as $extension) {
// Throw exception if extension isn't loaded
if (extension_loaded ($extension) === false) {
throw new \Exception (
'Failed to detect required extension: ' . $extension . '.'
);
}
}
}

// Checks if host is localhost
public function isLocalhost ($host)
{
// "localhost" equivalent addresses
$addresses = array ('127.0.0.1', 'localhost', '::1');

// Check if we're on localhost
$is_localhost = in_array ($host, $addresses, true);

return $is_localhost;
}

// Sanitizes given data for HTML use
protected function sanitizeData ($data = '')
{
// Strip HTML tags from data
$data = strip_tags (html_entity_decode ($data, false, 'UTF-8'));

// Encode HTML characters in data
$data = htmlspecialchars ($data, false, 'UTF-8', false);

return $data;
}

// Gets sanitized data from POST or GET data
protected function requestData ($data = '', $default = false)
{
// Attempt to obtain data via GET or POST
$request = $this->getRequest ($data, $default);

// Return on success
if ($request !== $default) {
$request = $this->sanitizeData ($request);
}

return $request;
}

// Sets path to website-specific threads directory
public function setWebsite ($host = 'request')
{
// Do nothing if multisites is disabled
if ($this->supportsMultisites === false) {
return;
}

// Attempt to obtain website from POST or GET if told to
if ($host === 'request') {
$host = $this->requestData ('website');
}

// Do nothing else if host is false
if ($host === false) {
return;
}

// Remove "www." from URL host
$host = str_replace ('www.', '', $host);

// Get directory safe name of host
$host = $this->getSafeDomainName ($host);

// Set host as "localhost" on local addresses
$host = $this->isLocalhost ($host) ? 'localhost' : $host;

// Append website host to comments directory path
$this->commentsPath = $this->joinPaths ($this->commentsRoot, $host);

// Set threads directory path
$this->threadsPath = $this->commentsPath . '/threads';

// Set thread directory path
$this->threadPath = $this->threadsPath . '/' . $this->threadName;

// And set website
$this->website = $host;
}

// Gets a domain with a port from given URL
protected function getDomainWithPort ($url = '')
{
// Parse URL
$url = parse_url ($url);

// Throw exception if URL or host is empty
if ($url === false or empty ($url['host'])) {
throw new \Exception (
'Failed to obtain domain name.'
);
}

// If URL has a port, return domain with port
if (!empty ($url['port'])) {
return $url['host'] . ':' . $url['port'];
}

// Otherwise, return domain without port
return $url['host'];
}

// Returns whether referer URL is located on the configured server
public function isSameServer ()
{
// Return true if no referer is set
if (empty ($_SERVER['HTTP_REFERER'])) return true;

// Otherwise, get HTTP referer domain with port
$domain = $this->getDomainWithPort ($_SERVER['HTTP_REFERER']);

// Return true if referer domain is the same server
if ($domain === $this->domain) return true;

// Otherwise, return false
return false;
}

// Enables and sets up remote access
public function enablesRemoteAccess ($force = false)
{
// Check if we're force enabling or only if referer is a different server
if ($force === true or $this->isSameServer () === false) {
// If so, set remote access indicator
$this->remoteAccess = true;

// Make HTTP root path absolute
$this->httpRoot = $this->joinPaths ($this->absolutePath, $this->httpRoot);

// Synchronize settings
$this->syncSettings ();
}
}

// Checks remote request against allowed domains setting
public function refererCheck ()
{
// Return true if no referer is set
if (empty ($_SERVER['HTTP_REFERER'])) return true;

// Otherwise, get HTTP referer domain with port
$domain = $this->getDomainWithPort ($_SERVER['HTTP_REFERER']);

// Return true if referer domain is the same server
if ($domain === $this->domain) return true;

// Otherwise, escape wildcard for regular expression
$sub_regex = '/^' . preg_quote ('\*\.') . '/S';

// Run through allowed domains
foreach ($this->allowedDomains as $allowed_domain) {
// Escape allowed domain for regular expression
$safe_domain = preg_quote ($allowed_domain);

// Replace subdomain wildcard with proper regular expression
$domain_regex = preg_replace ($sub_regex, '(?:.*?\.)*', $safe_domain);

// Final domain regular expression
$domain_regex = '/^' . $domain_regex . '$/iS';

// Check if script was requested from an allowed domain
if (preg_match ($domain_regex, $domain)) {
// If so, setup remote access
$this->enablesRemoteAccess (true);

// Connection origin
$origin = $this->scheme . '://' . $domain;

// Set remote access headers
header ('Access-Control-Allow-Origin: ' . $origin);
header ('Access-Control-Allow-Credentials: true');

// And return true
return true;
}
}

// Otherwise, throw exception
throw new \Exception (
'External use not allowed.'
);
}

// Strips magic quote escape slashes from string if present
public function stripMagicQuotes ($data)
{
// Check if magic quotes are supported
if (function_exists ('get_magic_quotes_gpc')) {
// If so, strip escape slashes from string if they are enabled
if (@get_magic_quotes_gpc ()) {
$data = stripslashes ($data);
}
}

// And return data
return $data;
}

// Processes POST or GET data depending on its type
protected function processRequest ($request)
{
// Check if GET or POST data is type string
if (gettype ($request) === 'string') {
// If so, strip escape slashes if enabled
$request = $this->stripMagicQuotes ($request);

// URL decode value
$request = urldecode ($request);
}

// And return POST or GET data
return $request;
}

// Gets value from POST or GET data
public function getRequest ($key, $default = false)
{
// Attempt to get and process POST data
if (!empty ($_POST[$key])) {
return $this->processRequest ($_POST[$key]);
}

// Attempt to get and process GET data
if (!empty ($_GET[$key])) {
return $this->processRequest ($_GET[$key]);
}

// Otherwise, return default
return $default;
}

// Gets current page URL
protected function getPageURL ()
{
// Attempt to obtain URL via POST or GET
$request = $this->getRequest ('url');

// Return URL from POST or GET if it exists
if ($request !== false) {
return $request;
}

// Otherwise, return HTTP referer if it exists
if (!empty ($_SERVER['HTTP_REFERER'])) {
return $_SERVER['HTTP_REFERER'];
}

// Otherwise, throw exception
throw new \Exception (
'Failed to obtain page URL.'
);
}

// Gets configured URL queries to be ignored
protected function getIgnoredQueries ()
{
// Initial ignored URL queries
$queries = array ();

// Ignored URL queries file
$ignored_queries = $this->getAbsolutePath ('config/ignored-queries.json');

// Check if ignored URL queries file exists
if (file_exists ($ignored_queries)) {
// If so, get ignored URL queries
$data = @file_get_contents ($ignored_queries);

// Parse ignored URL queries JSON file
$json = @json_decode ($data, true);

// Merge file with initial queries if parsed successfully
if ($json !== null) {
$queries = array_merge ($json, $queries);
}
}

return $queries;
}

// Filters URL queries for URLs and thread name usage
protected function filterURLQueries ($queries)
{
// Split queries by ampersand
$url_queries = explode ('&', $queries);

// Get configured queries to be ignored
$ignored_queries = $this->getIgnoredQueries ();

// Run through queries
for ($q = 0, $ql = count ($url_queries); $q < $ql; $q++) {
// Current URL query
$current = $url_queries[$q];

// Split current URL query by equals sign
$query_parts = explode ('=', $current);

// Current URL query name
$query = $query_parts[0];

// Skip default HashOver-specific URL queries
if (in_array ($query, $this->hashoverQueries, true)) {
continue;
}

// Store query for usage in URLs
$this->urlQueryList[] = $current;

// Skip name=value queries to be ignored in thread name
if (in_array ($current, $ignored_queries, true)) {
continue;
}

// Skip query names to be ignored entirely in thread name
if (in_array ($query, $ignored_queries, true)) {
continue;
}

// Store query thread name
$this->threadQueryList[] = $current;
}
}

// Reduces dashes to one per removed character
protected function reduceDashes ($name)
{
// Remove multiple dashes
if (mb_strpos ($name, '--') !== false) {
$name = preg_replace ('/-{2,}/', '-', $name);
}

// Remove leading and trailing dashes
$name = trim ($name, '-');

return $name;
}

// Gets an OS-agnostic safe directory name
protected function getSafeThreadName ($name)
{
// Replace reserved characters with dashes
$name = str_replace ($this->dashFromThreads, '-', $name);

// Remove multiple/leading/trailing dashes
$name = $this->reduceDashes ($name);

return $name;
}

// Gets an OS-agnostic safe directory name
protected function getSafeDomainName ($name)
{
// Replace reserved characters with dashes
$name = str_replace ($this->dashFromDomains, '-', $name);

// Remove multiple/leading/trailing dashes
$name = $this->reduceDashes ($name);

// Remove leading periods to prevent hiding in UNIX
$name = ltrim ($name, '.');

return $name;
}

// Sets comment thread to read comments from
public function setThreadName ($name = 'request')
{
// Request thread if told to
if ($name === 'request') {
$name = $this->requestData ('thread', $this->threadName);
}

// Convert thread to safe directory name
$name = $this->getSafeThreadName ($name);

// Set thread directory path
$this->threadPath = $this->joinPaths ($this->threadsPath, $name);

// And set thread name
$this->threadName = $name;
}

// Sets page URL
public function setPageURL ($url = 'request')
{
// Request page URL by default
if ($url === 'request') {
$url = $this->getPageURL ();
}

// Strip HTML tags from page URL
$url = strip_tags (html_entity_decode ($url, false, 'UTF-8'));

// Turn page URL into array
$parts = parse_url ($url);

// Throw exception if URL doesn't have a scheme or host
if (empty ($parts['scheme']) or empty ($parts['host'])) {
throw new \Exception (
'URL needs a hostname and scheme.'
);
}

// Set various paths to be website-specific
$this->setWebsite ($parts['host']);

// Check if URL has a path and is not the index
if (!empty ($parts['path']) and $parts['path'] !== '/') {
// If so, set file path property
$file_path = $parts['path'];

// And set thread name as path without slash
$thread_name = mb_substr ($parts['path'], 1);
} else {
// If not, set file path as a single slash
$file_path = '/';

// And set thread name as index
$thread_name = 'index';
}

// Check if URL has queries
if (!empty ($parts['query'])) {
// If so, filter queries in page URL
$this->filterURLQueries ($parts['query']);

// Store a string version of page URL queries
$this->urlQueries = implode ('&', $this->urlQueryList);

// Store a string version of thread name URL queries
$this->threadQueries = implode ('&', $this->threadQueryList);

// And add queries to thread name
$thread_name .= '-' . $this->threadQueries;
}

// Encode HTML characters in page URL
$url = htmlspecialchars ($url, false, 'UTF-8', false);

// Final URL scheme and host
$url = $parts['scheme'] . '://' . $parts['host'];

// Add optional port to URL
if (!empty ($parts['port'])) {
$url .= ':' . $parts['port'];
}

// Add file path to URL
$url .= $file_path;

// Add optional queries to URL
if (!empty ($this->urlQueries)) {
$url .= '?' . $this->urlQueries;
}

// Set relative file path
$this->filePath = $file_path;

// Check if we have a referrer URL
if (!empty ($_SERVER['HTTP_REFERER'])) {
// If so, construct absolute URL base
$absolute_path = $this->scheme . '://' . $parts['host'];

// Get HTTP referer domain with port
$referrer_domain = $this->getDomainWithPort ($_SERVER['HTTP_REFERER']);

// Set file path to absolute URL
if ($referrer_domain !== $parts['host']) {
$this->filePath = $this->joinPaths ($absolute_path, $file_path);
}
}

// Set thread directory name to page URL
$this->setThreadName ($thread_name);

// And set page URL property
$this->pageURL = $url;
}

// Sets page title
public function setPageTitle ($title = 'request')
{
// Check if we are getting title from POST/GET
if ($title === 'request') {
// If so, get sanitized POST/GET data
$title = $this->requestData ('title', '');
} else {
// If not, sanitize given title
$title = $this->sanitizeData ($title);
}

// And set page title
$this->pageTitle = $title;
}

// Sets instance number
public function setInstance ($instance = 'request')
{
// Request instance if told to
if ($instance === 'request') {
$instance = $this->getRequest ('instance', '');
}

// Throw exception if given instance is not numeric
if (!is_numeric ($instance)) {
throw new \Exception (
'Instance must be a number.'
);
}

// Otherwise, cast instance to actual integer
$this->instanceNumber = (int)($instance);
}
}