<?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;

/**
 * Used to serve resources, css, or scripts such as images from APP_DIR
 *
 * @author Chris Pollett
 */
class ResourceController extends Controller implements CrawlConstants
{
    /**
     * These are the activities supported by this controller
     * @var array
     */
    public $activities = ["get", "syncList", "syncNotify", "suggest"];
    /**
     * Checks that the request seems to be coming from a legitimate fetcher
     * or mirror server then determines which activity  is being requested
     * and calls the method for that activity.
     *
     */
    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();
    }
    /**
     * Gets the resource $_REQUEST['n'] from APP_DIR/$_REQUEST['f'] or
     * WORK_DIRECTORY/$_REQUEST['f']  after cleaning
     */
    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" &&
                    C\nsdefined("FAVICON") && !empty(C\p('FAVICON'))) {
                    $base_dir = C\APP_DIR . "/resources";
                    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 = C\APP_DIR . "/resources";
                    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 = C\APP_DIR . "/resources";
                    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;
        }
        $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;
            }
            if ($allow_cache) {
                $this->web_site->header("Cache-Control: public");
                if ($this->checkUnmodifiedAndProcess($path, $size)) {
                    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 */
                $this->web_site->header("Content-Length: " . $size);
                $this->web_site->header(
                    "Content-Range: bytes $start-$end/$size");
                readfile($path);
            } 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();
        }
    }
    /**
     * 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 under a per-domain subfolder; this turns that into the file's
     * name and folder on disk. 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");
        $subfolder = $this->clean($host, "file_name");
        $folder = C\APP_DIR . "/resources/" . $subfolder;
        if ($file_name == "" || !file_exists("$folder/$file_name")) {
            return ["", ""];
        }
        return [$file_name, $folder];
    }
    /**
     * Returns the file system folder where resources are stored
     * making use of the n field for the name of the resource, its type,
     * the sf field describing the desired subfolder
     * and whether this is a request for a thumbnail or a object
     * @param bool $is_src_folder should we look in the base directory
     *  (src folder) or work_directory to try to find the resource
     * @return array ordered pair [path beneath base folder to file, basefolder]
     */
    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) {
            $this->requestError();
        }
        $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];
    }
    /**
     * Handles requests that result in errors to this controller
     */
    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 beside the document.
     *
     * A thumbnail is kept in a page's thumb 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, so a
     * document uploaded and then only ever read had no picture and the
     * request served nothing. This finds the document in the sibling
     * resources folder 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);
        $leaf = substr($thumb_folder, strrpos($thumb_folder, "/") + 1);
        if (!str_starts_with($leaf, "t")) {
            return;
        }
        $source_folder = substr($thumb_folder, 0,
            strrpos($thumb_folder, "/") + 1) . substr($leaf, 1);
        $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));
    }
    /**
     * Computes based on the request the folder that should be used to
     * find a file during a resource get request. It also checks if user
     * has access to the requested folder and file. Finally, if this is
     * for a logged in user, it records the view in the user's session.
     *
     * @param string $media_name being requested, only used for logging, not
     *     computing base folder
     * @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";
        $add_to_path = false;
        $is_group_item = false;
        $page_id = "";
        if (!$is_src_folder && isset($_REQUEST['s'])&& !isset($_REQUEST['g']) &&
            $folder == "resources") {
            // handle sub-folders of resource (must be numeric)
            $subfolder = $this->clean($_REQUEST['s'], "hash");
            $prefix_folder = substr($subfolder, 0, 3);
            $add_to_path = true;
        } else if (!$is_src_folder && isset($_REQUEST['g'])) {
            $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);
            $prefix_word = (isset($_REQUEST['t'])) ? 't' : '';
            $base_subfolder = L\crawlHash(
                'group' . $group_id . $page_id . C\p('AUTH_KEY'));
            $prefix_folder = substr($base_subfolder, 0, 3);
            $subfolder = $prefix_word . $base_subfolder;
            $add_to_path = true;
            $is_group_item = true;
        }
        if ($add_to_path) {
            if ($is_group_item) {
                $redirect_dir = "$base_dir/$prefix_folder/$base_subfolder";
            }
            if ($is_group_item &&
                file_exists($redirect_dir . "/redirect.txt")) {
                $tmp_path = $this->web_site->fileGetContents($redirect_dir .
                    "/redirect.txt");
                if (is_dir($tmp_path)) {
                    if ($subfolder == $base_subfolder) {
                        $base_dir = $tmp_path;
                    } else {
                        $subfolder = L\crawlHash($tmp_path);
                        $prefix_folder = substr($subfolder, 0, 3);
                        $subfolder = $prefix_word . $subfolder;
                        $base_dir .= "/$prefix_folder/$subfolder";
                    }
                }
            } else {
                $base_dir .= "/$prefix_folder/$subfolder";
            }
        }
        return $base_dir;
    }
    /**
     * Code to handle HTTP range requests of resources. This allows
     * HTTP pseudo-streaming of video. This code was inspired by:
     * 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();
    }
    /**
     * Used to get a keyword suggest trie. This sends additional
     * header so will be decompressed on the fly
     */
    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 = C\LOCALE_DIR . "/$locale/resources/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;
            }
            readfile($path);
        }
    }
    /**
     * Checks if a request is for a file that was cached and not size modified.
     * If so, processes and output 304 headers
     *
     * @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;
    }
    /**
     * Used to notify a machine that another machine acting as a mirror
     * is still alive. Data is stored in a txt file self::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));
            }
        }
    }
    /**
     * Returns a list of syncable files and the modification times
     */
    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)));
    }
}
