<?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\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\FetchUrl;
use seekquarry\yioop\library\UrlParser;
use seekquarry\yioop\library\MediaConstants;
use seekquarry\yioop\library\processors\ImageProcessor;
/**
* ResourceController sends files: style sheets, scripts and locale files
* from src or the work directory's app folder, and from app/resources the
* files of wiki pages and posts, feed item pictures, users' icons and the
* site's and hosts' appearance files, after checking the reader may see
* them. It also gives mirrors the list of files to copy, and sends the
* word-suggest data of a locale.
*
* @author Chris Pollett
*/
class ResourceController extends Controller implements CrawlConstants
{
/**
* COMPRESSIBLE_TYPES are the types of file sent gzip compressed when a
* request accepts it: text that compresses to a fraction of its size.
* Pictures and video are already compressed and are left as they are.
*/
const COMPRESSIBLE_TYPES = ["text/css", "text/javascript",
"application/javascript", "application/json", "text/html",
"text/plain", "image/svg+xml", "application/xml", "text/xml"];
/**
* activities are the values of a that this controller answers
* @var array
*/
public $activities = ["get", "syncList", "syncNotify", "suggest"];
/**
* page_base_folder is the folder of the files of the wiki page or post
* the current request is for, as checkAndLogViewGetBaseFolder found
* it, so makeMissingThumb can find the file a missing thumbnail is
* made from; "" when the request is not for a page or post.
* @var string
*/
public $page_base_folder = "";
/**
* processRequest calls the method of the activity the request names in
* a. Anyone may ask for get and suggest; syncList and syncNotify are
* answered only for a request checkRequest finds comes from one of
* the site's own machines, such as a mirror. Anything else gets
* requestError.
*/
public function processRequest()
{
if ((isset($_REQUEST['a']) && in_array(
$_REQUEST['a'], ["get", "suggest"]))
|| $this->checkRequest()) {
$activity = $_REQUEST['a'];
if (in_array($activity, $this->activities)) {
$this->call($activity);
return;
}
}
$this->requestError();
}
/**
* get sends the file the request names in n. With b, it is read from
* the src folder: the site's favicon.ico, robots.txt or yioopbar.xml,
* which the site folder of app/resources or a host's folder may
* replace, or a file of src's css, scripts, resources or locale. With
* f, it is read from the work directory: from app's css or scripts,
* from app/resources as checkAndLogViewGetBaseFolder allows, from
* locale, or, for the site's own machines, from cache. A file is sent
* whole, gzip compressed, as a byte range, or as the window o and l
* name, and a thumbnail or feed picture missing from disk is made
* first.
*/
public function get()
{
if (!isset($_REQUEST['n']) || !(isset($_REQUEST['f']) ||
isset($_REQUEST['b']))) {
$this->requestError();
}
if (!empty($_REQUEST['b'])) {
if (in_array($_REQUEST['b'], ["favicon.ico", "robots.txt",
"yioopbar.xml"])) {
$name = $_REQUEST['b'];
$base_dir = C\BASE_DIR;
$use_global_favicon = true;
if ($name == "favicon.ico") {
list($domain_name, $domain_dir) =
$this->domainFavicon();
if ($domain_name != "") {
$name = $domain_name;
$base_dir = $domain_dir;
$use_global_favicon = false;
}
}
if ($use_global_favicon && $name == "favicon.ico") {
$base_dir = L\ResourceFolders::sitePath();
if (!file_exists("$base_dir/$name")) {
$base_dir = C\BASE_DIR;
}
}
if ($name == "robots.txt" && C\nsdefined("ROBOTS_TXT") &&
!empty(C\ROBOTS_TXT) != "") {
$base_dir = L\ResourceFolders::sitePath();
if (!file_exists("$base_dir/$name")) {
$base_dir = C\BASE_DIR;
}
}
if ($name == "yioopbar.xml" && C\nsdefined("YIOOPBAR_XML") &&
!empty(C\YIOOPBAR_XML) != "") {
$base_dir = L\ResourceFolders::sitePath();
if (!file_exists("$base_dir/$name")) {
$base_dir = C\BASE_DIR;
}
}
} else if (in_array($_REQUEST['b'], ["css", "scripts",
"resources"])) {
list($name, $base_dir) = $this->getNameAndBaseFolder(true);
} else if (in_array($_REQUEST['b'], ["locale"])) {
$name = $this->clean($_REQUEST['n'], "file_name");
$folder = $_REQUEST['b'];
$base_dir = C\BASE_DIR . "/$folder";
}
} else if (!empty($_REQUEST['f'])) {
if (in_array($_REQUEST['f'], ["css", "scripts", "resources"])) {
list($name, $base_dir) = $this->getNameAndBaseFolder();
} else if (in_array($_REQUEST['f'], ["cache"])) {
/* perform check since these request should come from a known
machine
*/
if (!$this->checkRequest()) {
$this->requestError();
}
$name = $this->clean($_REQUEST['n'], "file_name");
$folder = $_REQUEST['f'];
$base_dir = C\WORK_DIRECTORY . "/$folder";
} else if (in_array($_REQUEST['f'], ["locale"])) {
$name = $this->clean($_REQUEST['n'], "file_name");
$sub_path = "";
if (!empty($_REQUEST['sf'])) {
$sub_path = $this->clean($_REQUEST['sf'], "string");
$sub_path = str_replace(".", "", $sub_path) . "/";
if ($sub_path == "/") {
$sub_path = "";
}
}
$folder = $_REQUEST['f'];
$base_dir = C\APP_DIR . "/$folder";
$name = $sub_path . $name;
} else {
$this->requestError();
return;
}
} else {
$this->requestError();
return;
}
if (empty($base_dir)) {
$this->requestError();
return;
}
$allow_cache = true;
if ((isset($_REQUEST['o']) && isset($_REQUEST['l'])) ) {
$offset = $this->clean($_REQUEST['o'], "int");
$limit = $this->clean($_REQUEST['l'], "int");
$allow_cache = false;
}
$path = "$base_dir/$name";
if (isset($_REQUEST['t']) && $_REQUEST['t'] == 'feed' &&
!file_exists($path) && file_exists("$path.txt")) {
$image_url = $this->web_site->fileGetContents("$path.txt");
if (!empty($image_url)) {
$image_page = FetchUrl::getPage($image_url);
set_error_handler(null);
$image = @imagecreatefromstring($image_page);
restore_error_handler();
$thumb = ImageProcessor::createThumb($image);
if (!empty($thumb)) {
$this->web_site->filePutContents($path, $thumb);
}
}
}
if (str_ends_with($path, 'webp')) {
$alt_path = substr($path, 0, -4) . "jpg";
if (!file_exists($path) && file_exists($alt_path)) {
$path = $alt_path;
}
}
if (!empty($_REQUEST['t']) && $_REQUEST['t'] != 'feed' &&
!file_exists($path)) {
$this->makeMissingThumb($base_dir, $name);
}
if (str_ends_with($path, 'webp')) {
$alt_path = substr($path, 0, -4) . "jpg";
if (!file_exists($path) && file_exists($alt_path)) {
$path = $alt_path;
}
}
if (file_exists($path)) {
$path = realpath($path);
/*
Detect the type from the file extension rather than
by reading the file's contents. finfo opens the file
and reads a magic-number prefix, which on a large
media file (an hour-long video, say) is a disk read
of tens to hundreds of milliseconds, paid again on
every range request a player makes while seeking. The
resource is served by a known name here, so the
extension is authoritative; this matches how named
resources are typed elsewhere (SocialComponent,
GroupModel) and removes the per-seek stall.
*/
$mime_type = L\mimeType($path);
$mime_parts = explode("/", $mime_type);
$size = filesize($path);
$start = 0;
$end = $size - 1;
$this->web_site->header("Content-type: $mime_type");
$keep_it = !empty($_REQUEST['download']);
$this->web_site->header('Content-Disposition: ' .
($keep_it ? 'attachment; ' : '') . 'filename="' .
$name . '"');
$this->web_site->header("Accept-Ranges: bytes");
if (isset($_SERVER['HTTP_RANGE'])) {
$this->serveRangeRequest($path, $size, $start, $end);
return;
}
$compress = $this->compressWhenSending($mime_type, $size);
$body = null;
if ($compress) {
$body = $this->web_site->fileGetContents($path, false, true);
$this->web_site->header("Vary: Accept-Encoding");
$this->web_site->header("Content-Encoding: gzip");
}
if ($allow_cache) {
$this->web_site->header("Cache-Control: public");
if ($this->checkUnmodifiedAndProcess($path,
($body === null) ? $size : strlen($body))) {
return;
}
}
if ($body !== null) {
$this->web_site->header("Content-Length: " . strlen($body));
echo $body;
return;
}
if (isset($offset) && isset($limit)) {
/* A byte window requested by offset and limit: the
machine-to-machine resource sync (c=resource a=get
with o and l) fetches large files in fixed spans
this way. Stream the window in blocks rather than
reading it whole with file_get_contents. A large
limit (a 50 MB sync span, or a crafted request
asking for a whole multi-hundred-megabyte file at
once) would otherwise hold the entire span in
memory and, copied again into the server's response
buffer, was able to exhaust the process. The
headers are left exactly as before so the sync
callers see the same response. */
$this->web_site->header("Content-Length: " . $size);
$this->web_site->header(
"Content-Range: bytes $start-$end/$size");
set_error_handler(null);
$whole_file = $path;
$serve_offset = $offset;
$serve_limit = $limit;
$this->web_site->stream(function () use ($whole_file,
$serve_offset, $serve_limit) {
$fh = fopen($whole_file, "rb");
try {
fseek($fh, $serve_offset);
$remaining = $serve_limit;
while ($remaining > 0 && !feof($fh)) {
$buffer = ($remaining <
C\RESOURCE_STREAM_BLOCK_LEN) ?
$remaining : C\RESOURCE_STREAM_BLOCK_LEN;
$bytes = fread($fh, $buffer);
if ($bytes === false || $bytes === "") {
break;
}
yield $bytes;
$remaining -= strlen($bytes);
}
} finally {
fclose($fh);
}
});
restore_error_handler();
} else if ($size <= C\MAX_RESOURCE_SERVE_LEN) {
/* small files (thumbnails, images, scripts) go out
buffered through the web server's normal response
path, which drains asynchronously without holding
up other requests */
/* The length is taken from the bytes sent, which may
come from the web server's cache of small files, so
the header and the body always agree. */
$body = $this->web_site->fileGetContents($path);
$sent = strlen($body);
$this->web_site->header("Content-Length: " . $sent);
$this->web_site->header("Content-Range: bytes 0-" .
($sent - 1) . "/$sent");
echo $body;
} else {
/* No Range header, large file: stream the whole file
as a 200. The atto server paces the body to the
flow-control credit the client returns as it reads,
so even a very large file goes out memory-bounded
without holding it in memory or blocking other
streams on the connection. */
$end = $size - 1;
$length = $size;
$this->web_site->header("Content-Length: " . $length);
set_error_handler(null);
$whole_file = $path;
$serve_end = $end;
if ($this->web_site->deferBodyFromFile($whole_file)) {
/* The event loop drains the file a piece at a time
between serving other connections. Reading it here
instead held the one process for as long as the
download took, so a hundred megabyte video stopped
the site answering anything else while it went
out. */
restore_error_handler();
return;
}
$this->web_site->stream(function () use ($whole_file,
$serve_end) {
$fh = fopen($whole_file, "rb");
try {
$position = 0;
$buffer = C\RESOURCE_STREAM_BLOCK_LEN;
while (!feof($fh) && $position <= $serve_end) {
if ($position + $buffer > $serve_end) {
$buffer = $serve_end - $position + 1;
}
$bytes = fread($fh, $buffer);
if ($bytes === false || $bytes === "") {
break;
}
yield $bytes;
$position += strlen($bytes);
}
} finally {
fclose($fh);
}
});
restore_error_handler();
}
} else {
$this->requestError();
}
}
/**
* domainFavicon finds the favicon set for the domain the current
* request came in on, so a browser asking for /favicon.ico gets that
* domain's icon rather
* than the one global icon. A page with no favicon link of its own,
* such as a served static HTML file, falls back to /favicon.ico, and
* without this every domain's such pages would show the same shared
* icon. The domain's saved appearance holds the favicon as a resource
* url naming the file; the file is in the host's folder,
* ResourceFolders::domainPath. When the request host has no saved
* favicon,
* or the file is missing, two empty strings are returned so the caller
* keeps the global favicon.
*
* @return array [favicon file name, folder holding it], or two empty
* strings when the request domain has no favicon of its own
*/
private function domainFavicon()
{
$host = strtolower($_SERVER['HTTP_HOST'] ?? "");
$colon = strrpos($host, ':');
if ($colon !== false && strpos($host, ']') === false) {
$host = substr($host, 0, $colon);
}
if ($host == "") {
return ["", ""];
}
$appearance = $this->model("group")->getDomainAppearance($host);
if (empty($appearance['FAVICON'])) {
return ["", ""];
}
$favicon = html_entity_decode($appearance['FAVICON']);
if (strpos($favicon, "n=") !== false) {
$pieces = explode("n=", $favicon);
$file_name = array_pop($pieces);
} else {
$file_name = basename($favicon);
}
$file_name = $this->clean($file_name, "file_name");
$folder = L\ResourceFolders::domainPath($host);
if ($file_name == "" || $folder == "" ||
!file_exists("$folder/$file_name")) {
return ["", ""];
}
return [$file_name, $folder];
}
/**
* getNameAndBaseFolder gives the folder a request reads from and the
* file's path below it: n cleaned as a file name, below the sub-folder
* sf names, with a picture ending added for a thumbnail request. The
* folder comes from checkAndLogViewGetBaseFolder, which also checks the
* reader may read it.
* @param bool $is_src_folder whether to read from the src folder rather
* than the work directory
* @return array [path of the file below the folder, the folder], with
* false for the folder when the request may not be answered
*/
public function getNameAndBaseFolder($is_src_folder = false)
{
$name = $this->clean($_REQUEST['n'] ?? "", "file_name");
$type = UrlParser::getDocumentType($name);
if (isset($_REQUEST['feed']) ||
(!empty($_REQUEST['t']) && $_REQUEST['t'] == 'feed')) {
$_REQUEST['t'] = 'feed';
$type = "";
unset($_REQUEST['p']);
}
/* notice in this case we didn't check if request come from a
legitimate source but we do try to restrict it to being
a file (not a folder) in the above array. If the request
is for a file in resources, then if it is for a private
group, we will check in checkAndLogViewGetBaseFolder
if the request is legit
*/
$base_dir = $this->checkAndLogViewGetBaseFolder($name, $is_src_folder);
if (!$base_dir) {
/* the caller stops on a false folder; going on would make
the path from the file system's root */
return [$name, false];
}
$name = urlencode($name);
$name = UrlParser::getDocumentFilename($name);
$name = urldecode($name);
$name = ($type != "") ? "$name.$type" : $name;
if (!$is_src_folder) {
$sub_path = "";
if (!empty($_REQUEST['sf'])) {
$sub_path = $this->clean($_REQUEST['sf'], "string");
$sub_path = str_replace("..", "", $sub_path);
$sub_path = str_replace("/./", "/", $sub_path);
$sub_path = "$sub_path/";
if ($sub_path == "/") {
$sub_path = "";
}
}
if (!empty($_REQUEST['t'])) {
/* A thumbnail is kept beside its file under the file's
whole name with a picture ending added, so the picture
for a_picture.webp is a_picture.webp.webp. A file that
already ends in the ending takes it only where the
thumbnail is really there beside it, so the check must
look in the sub-folder the file lives in, not the page
root. */
$ending = ($_REQUEST['t'] == C\MOVING_THUMB_ARG) ?
C\MOVING_THUMB_ENDING : ".webp";
if (substr($name, -strlen($ending)) !== $ending ||
file_exists("$base_dir/$sub_path$name$ending")) {
$name .= $ending;
}
}
$name = $sub_path . $name;
}
return [$name, $base_dir];
}
/**
* requestError sends the reader to the site's error page, for a request
* this controller does not answer
*/
public function requestError()
{
$error = B\directUrl("error", false, true);
$error_location = "Location: $error";
$this->web_site->header($error_location);
return;
}
/**
* makeMissingThumb makes the small picture a thumbnail request asks
* for when none is yet in the page's thumbs folder.
*
* A thumbnail is kept in a page's thumbs folder under the document's
* whole name with a picture ending. The media list makes it when a
* page is edited, but a page read in read mode asks the resource
* controller for it directly and never goes through that list. This
* finds the document in the page's files folder, page_base_folder as
* checkAndLogViewGetBaseFolder found it, and asks the group model to
* make its picture, the same call the list uses.
*
* @param string $thumb_folder the folder the picture belongs in, the
* folder named in the request
* @param string $thumb_name the picture's name, the document's whole
* name with a picture ending such as ".webp" added
* @return void
*/
public function makeMissingThumb($thumb_folder, $thumb_name)
{
$ending_at = strrpos($thumb_name, ".");
if ($ending_at === false) {
return;
}
$document_name = substr($thumb_name, 0, $ending_at);
$source_folder = $this->page_base_folder;
if ($source_folder == "") {
return;
}
$source_path = "$source_folder/$document_name";
if (!file_exists($source_path)) {
return;
}
$this->model('group')->makeThumbStripExif($document_name,
$source_folder, $thumb_folder, L\mimeType($source_path));
}
/**
* checkAndLogViewGetBaseFolder works out from the request the folder a
* resource get request reads from, and checks the reader may read it.
* For f=resources there are three kinds of request:
* with g, a group's page, post or feed pictures, read only after the
* group check below, and with the view recorded in the session; with s
* and no g, a user's icon, as userResourceFolder says; with neither, the
* site's or a host's appearance files, as siteResourceFolder says. So
* no request reaches a page's or post's folder without the group
* check, and the names of folders need not be secret.
*
* @param string $media_name the file being requested, n cleaned as a
* file name; used for logging and to check a user request against
* the safe list
* @param bool $is_src_folder if the request is for a file in the src
* folder as a opposed to the work directory folder
* @return mixed either a string with the folder name in it or false if
* the user does not have access or that folder does not exist.
*/
public function checkAndLogViewGetBaseFolder($media_name = "",
$is_src_folder = false)
{
if ($is_src_folder) {
$folder = empty($_REQUEST['b']) ? "" :
$this->clean($_REQUEST['b'], 'string');
} else {
$folder = empty($_REQUEST['f']) ? "" :
$this->clean($_REQUEST['f'], 'string');
}
$base_dir = ($is_src_folder) ? C\BASE_DIR . "/$folder" :
C\APP_DIR . "/$folder";
$page_id = "";
$this->page_base_folder = "";
if ($is_src_folder || $folder != "resources") {
return $base_dir;
}
if (isset($_REQUEST['s']) && !isset($_REQUEST['g'])) {
return $this->userResourceFolder($media_name);
} else if (!isset($_REQUEST['g'])) {
return $this->siteResourceFolder();
} else {
$user_id = isset($_SESSION['USER_ID']) ? $_SESSION['USER_ID'] :
C\PUBLIC_USER_ID;
if (isset($_REQUEST['p'])) {
$page_id = $this->clean($_REQUEST['p'], 'string');
}
$group_id = $this->clean($_REQUEST['g'], "int");
$group_model = $this->model('group');
$wiki_model = $this->model('wiki');
$token_okay = true;
$pre_token_okay = $this->checkCSRFToken(C\p('CSRF_TOKEN'),
$user_id);
if ($group_id == C\PUBLIC_GROUP_ID) {
/* Anyone may read the public group's resources, so a
request that names nobody is read as the public
reader. Someone signed in stays who they are: writing
over their id made a signed-in reader anonymous the
moment they touched a public group's file, and the
check below that asks what an anonymous reader may see
then refused them a page of their own. */
if (empty($_SESSION['USER_ID'])) {
$user_id = C\PUBLIC_USER_ID;
} else {
$token_okay = $pre_token_okay;
}
} else {
$token_okay = $pre_token_okay;
if (empty($_COOKIE) && stristr($_SERVER['HTTP_USER_AGENT'],
"Mobile") !== false && stristr($_SERVER['HTTP_USER_AGENT'],
"Safari") !== false) {
$this->web_site->header('HTTP/1.0 403 Forbidden');
//fixes mobile safari no send cookie bug
\seekquarry\atto\webExit();
}
}
$group = $group_model->getGroupById($group_id, $user_id);
if (empty($group)) {
$group_as_root = $group_model->getGroupById($group_id,
C\ROOT_ID);
if (!empty($group_as_root) &&
$group_as_root['REGISTER_TYPE'] == C\PUBLIC_JOIN) {
$group = $group_as_root;
$token_okay = true;
}
}
if (!$group || (!$token_okay &&
($group['REGISTER_TYPE'] ?? -1) != C\PUBLIC_JOIN)) {
return false;
}
if ($user_id == C\PUBLIC_USER_ID &&
$media_name == "form_data.csv") {
if (!empty($page_id) ) {
$page_info = $wiki_model->getPageInfoByPageId($page_id);
$page_info = $wiki_model->getPageInfoByName($group_id,
$page_info['PAGE_NAME'] ?? "", L\getLocaleTag(),
'edit');
$view = $this->view("static");
$this->parsePageHeadVarsView($view, $page_id,
$page_info["PAGE"] ?? "");
$head = $view->head_objects[$page_id];
if (empty($head["public_source"]) ||
$head["public_source"] != 'true') {
return false;
}
} else {
return false;
}
}
$sub_path = (empty($_REQUEST['sf'])) ? "" :
$this->clean($_REQUEST['sf'], "string");
$this->recordViewSession($page_id, $sub_path, $media_name);
}
if (($_REQUEST['t'] ?? "") == 'feed') {
return ($group_id == C\PUBLIC_GROUP_ID) ?
L\ResourceFolders::feedsPath() : false;
}
$folders = $group_model->getGroupPageResourcesFolders($group_id,
$page_id);
if (empty($folders)) {
return false;
}
$this->page_base_folder = $folders[2];
return isset($_REQUEST['t']) ? $folders[3] : $folders[2];
}
/**
* userResourceFolder gives the folder a request naming a user, with s
* holding the user's id and no g, may read: that user's folder under
* app/resources/users, as ResourceFolders::userPath names it. Only the
* files in ResourceFolders::USER_FILE_SAFE_LIST may be read this way,
* with no sub-folder and no thumbnail, since no group check is made.
*
* @param string $media_name the file asked for, n cleaned as a file name
* @return string|bool the folder, or false when the file may not be
* read this way or the user is unknown
*/
private function userResourceFolder($media_name)
{
if (!in_array($media_name, L\ResourceFolders::USER_FILE_SAFE_LIST,
true) || !empty($_REQUEST['sf']) || !empty($_REQUEST['t'])) {
return false;
}
$user_id = $this->clean($_REQUEST['s'], "int");
$user_name = $this->model("signin")->getUserName($user_id);
if (empty($user_name)) {
return false;
}
return L\ResourceFolders::userPath($user_name);
}
/**
* siteResourceFolder gives the folder a request with f=resources and
* neither g nor s may read. These requests carry the site's
* appearance files, which live in ResourceFolders::sitePath, and with
* sf naming a host, that host's appearance files, which live in
* ResourceFolders::domainPath. Nothing else under app/resources can be
* reached this way, and no thumbnail is made. The host is taken out of
* the request once used, so getNameAndBaseFolder does not add it to
* the path a second time.
*
* @return string|bool the folder, or false for a thumbnail request or
* for an sf that is not a host name as domainPath keeps it
*/
private function siteResourceFolder()
{
if (!empty($_REQUEST['t'])) {
return false;
}
if (empty($_REQUEST['sf'])) {
return L\ResourceFolders::sitePath();
}
$host = strtolower((string)$_REQUEST['sf']);
unset($_REQUEST['sf']);
$domain_path = L\ResourceFolders::domainPath($host);
if ($domain_path === "" || basename($domain_path) !== $host) {
return false;
}
return $domain_path;
}
/**
* serveRangeRequest answers a request for a byte range of a file, as
* the Range header asks, which lets a video player start and seek
* through a video before it has the whole file. It follows the
* approach of http://www.tuxxin.com/php-mp4-streaming/.
*
* @param string $file Name of file to serve range request for
* @param int $size size of the file in bytes
* @param int $start starting byte location want to serve
* @param int $end ending byte location want ot serve
*/
public function serveRangeRequest($file, $size, $start, $end)
{
$current_start = $start;
$current_end = $end;
$pre_range_parts = explode('=', $_SERVER['HTTP_RANGE'], 2);
$range = ",";
if (count($pre_range_parts) == 2) {
list(, $range) = $pre_range_parts;
}
if (str_contains($range, ',')) {
$this->web_site->header(
'HTTP/1.1 416 Requested Range Not Satisfiable');
$this->web_site->header("Content-Range: bytes $start-$end/$size");
return;
}
if ($range == '-') {
$current_start = $size - 1;
} else {
$range = explode('-', $range);
$current_start = trim($range[0]);
$current_end = (isset($range[1]) && is_numeric(trim($range[1])))
? trim($range[1]) : $size;
if ($current_start === "") {
$current_start = max(0, $size - $range[1] - 1);
}
}
$current_end = ($current_end > $end) ? $end : $current_end;
if ($current_start > $current_end || $current_start > $size - 1 ||
$current_end >= $size) {
$this->web_site->header(
'HTTP/1.1 416 Requested Range Not Satisfiable');
$this->web_site->header("Content-Range: bytes $start-$end/$size");
return;
}
$start = $current_start;
$end = $current_end;
/*
Serve the whole range the client asked for. The body is
streamed a block at a time below and the atto server paces
it to the flow-control credit the client returns as it
reads, so an open-ended or very large range goes out
memory-bounded without holding the whole span at once and
without blocking other streams on the connection. An
earlier version capped the span to a fixed size or to the
window known at request time; both were removed. A client
enlarges its stream window with a WINDOW_UPDATE processed
just after the request that opened the stream, so a
request-time cap only ever saw the small initial window,
and naming a shorter span than the client asked for made
Firefox and Safari treat the reply as a partial transfer
and fail rather than fetch the remainder.
*/
$length = $end - $start + 1;
set_error_handler(null);
$fp = @fopen($file, 'rb');
fseek($fp, $start);
$this->web_site->header('HTTP/1.1 206 Partial Content');
$this->web_site->header("Content-Range: bytes $start-$end/$size");
$this->web_site->header("Content-Length: ".$length);
/*
Stream the range with a generator so the whole span never
has to be held in memory and there is no need to cap a
long open-ended range to a fixed chunk. The atto server
advances the generator from its event loop as the player
returns flow-control credit, so an arbitrarily large range
is served memory-bounded over H2 and other streams on the
connection keep being served. Over H1 the emitter echoes
and flushes each block (and stops on a seek-aborted
request); off the atto server it produces the body whole.
The file handle is closed in finally so a reset or
disconnect that discards the generator still releases it.
*/
$serve_start = $start;
$serve_end = $end;
$this->web_site->stream(function () use ($fp, $serve_start,
$serve_end) {
try {
$position = $serve_start;
$buffer = C\RESOURCE_STREAM_BLOCK_LEN;
while (!feof($fp) && $position <= $serve_end) {
if ($position + $buffer > $serve_end) {
$buffer = $serve_end - $position + 1;
}
$bytes = fread($fp, $buffer);
if ($bytes === false || $bytes === "") {
break;
}
yield $bytes;
$position += strlen($bytes);
}
} finally {
fclose($fp);
}
});
restore_error_handler();
}
/**
* suggest sends the word-suggest trie of the locale the request names,
* the compressed file the search box's word suggestions are read
* from, with a header that has the browser decompress it as it
* arrives
*/
public function suggest()
{
if (!isset($_REQUEST["locale"])){
return;
}
$locale = $_REQUEST["locale"];
$count = preg_match("/^[a-zA-z]{2}(-[a-zA-z]{2})?$/", $locale);
if ($count != 1) {
return;
}
$locale = str_replace("-", "_", $locale);
$path = L\localeResource($locale, "suggest_trie.txt.gz");
if (file_exists($path)) {
$size = filesize($path);
$this->web_site->header("Cache-Control: public");
$this->web_site->header("Content-Type: application/json");
$this->web_site->header("Content-Encoding: gzip");
$this->web_site->header("Content-Length: " . $size);
if ($this->checkUnmodifiedAndProcess($path, $size)) {
return;
}
echo $this->web_site->fileGetContents($path);
}
}
/**
* compressWhenSending says whether a file is sent gzip compressed. It
* is when the file is a text type such as a style sheet or a script,
* the request says it accepts gzip, no byte range or window of the
* file is asked for, and the file is no larger than
* MAX_RESOURCE_SERVE_LEN. Pictures and video are already compressed
* and are sent as they are. get calls this before it reads a file.
* @param string $mime_type the file's type, from its name
* @param int $size the file's size in bytes
* @return bool true to send the gzip compressed contents
*/
public function compressWhenSending($mime_type, $size)
{
return $size <= C\MAX_RESOURCE_SERVE_LEN &&
!isset($_SERVER['HTTP_RANGE']) && !isset($_REQUEST['o']) &&
!isset($_REQUEST['l']) && in_array($mime_type,
self::COMPRESSIBLE_TYPES) &&
stripos($_SERVER['HTTP_ACCEPT_ENCODING'] ?? "", "gzip") !== false;
}
/**
* checkUnmodifiedAndProcess sends the caching headers of a file, and
* when the reader already holds the file as it is now, as the request's
* If-Modified-Since and If-None-Match say, sends 304 Not Modified in
* place of it
*
* @param string $path file system path to file resource
* @param int $size size of file
* @return bool whether resource hasn't changed (true) or has (false)
*/
public function checkUnmodifiedAndProcess($path, $size)
{
$expires = gmdate( "D, d M Y H:i:s", time() +
intval(C\RESOURCE_CACHE_TIME))." GMT";
$this->web_site->header("Expires: $expires");
$last_modified = filemtime($path);
$this->web_site->header("Last-Modified: " .
gmdate( "D, d M Y H:i:s", $last_modified )." GMT" );
$modified_since = (isset( $_SERVER["HTTP_IF_MODIFIED_SINCE"]))
? strtotime($_SERVER["HTTP_IF_MODIFIED_SINCE"]) : false;
$etag_header = (isset($_SERVER["HTTP_IF_NONE_MATCH"])) ?
trim($_SERVER["HTTP_IF_NONE_MATCH"]) : false;
//very crude etag
$etag = sprintf( '"%s-%s"', $last_modified, $size);
$this->web_site->header("Etag: $etag");
if ($modified_since === $last_modified &&
$etag === $etag_header) {
$this->web_site->header("HTTP/1.1 304 Not Modified");
return true;
}
return false;
}
/**
* syncNotify records that a mirror, a machine copying this site, has
* just asked for files, so this machine knows it is still running. The
* record is kept in the work directory's file named by
* mirror_table_name
*/
public function syncNotify()
{
if (isset($_REQUEST['last_sync']) && $_REQUEST['last_sync'] > 0 ) {
$mirror_table_name = C\WORK_DIRECTORY . "/" .
self::mirror_table_name;
$mirror_table = [];
$time = time();
if (file_exists($mirror_table_name) ) {
$mirror_table = unserialize(
$this->web_site->fileGetContents($mirror_table_name));
if (isset($mirror_table['time']) &&
$mirror_table['time'] - $time > C\MIRROR_SYNC_FREQUENCY) {
$mirror_table = [];
// truncate table periodically to get rid of stale entries
}
}
if (isset($_REQUEST['robot_instance'])) {
$mirror_table['time'] = $time;
$mirror_table['machines'][
$this->clean($_REQUEST['robot_instance'], "string")] =
[L\remoteAddress(), $_REQUEST['machine_uri'],
$time,
$this->clean($_REQUEST['last_sync'], "int")];
$this->web_site->filePutContents($mirror_table_name,
serialize($mirror_table));
}
}
}
/**
* syncList sends a mirror the files it may copy that changed since its
* last copy, with their times of change, and records the request as
* syncNotify does
*/
public function syncList()
{
$this->syncNotify();
$info = [];
if (isset($_REQUEST["last_sync"])) {
$last_sync = $this->clean($_REQUEST["last_sync"], "int");
} else {
$last_sync = 0;
}
// substrings to exclude from our list
$excludes = [".DS", "__MACOSX", "queries", "QueueBundle", "tmp",
"thumb"];
$sync_files = $this->model("crawl")->getDeltaFileInfo(
C\CACHE_DIR . "/", $last_sync, $excludes);
if (count($sync_files) > 0 ) {
$info[self::STATUS] = self::CONTINUE_STATE;
$info[self::DATA] = $sync_files;
} else {
$info[self::STATUS] = self::NO_DATA_STATE;
}
echo base64_encode(gzcompress(serialize($info)));
}
}