SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / library / HotTargets.php
<?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\library;

use seekquarry\yioop\configs as C;

/**
 * HotTargets keeps a count of the times a request for a wiki page's history
 * or source has been refused, by the page asked for rather than by the
 * one who asked, and says when a page has drawn enough refusals in a short
 * time that further requests for it can be turned away before the site
 * does any work. A scraper that rotates its address is not slowed by a
 * count kept per address, since each request arrives from a fresh one;
 * what it cannot change is the page it wants, so the count is kept there.
 * The counts live in one small file under the temp directory so reading
 * them costs a file read and no database. Controller::refuseForbidden
 * records each refusal here, and index.php asks before starting a session.
 *
 * @author Chris Pollett
 */
class HotTargets
{
    /**
     * Name of the file, under the temp directory, holding the counts.
     */
    const FILE_NAME = "HotTargets.txt";
    /**
     * How many refusals of one target in the window make it hot.
     */
    const REFUSALS_TO_BE_HOT = 20;
    /**
     * How long, in seconds, refusals of a target are counted toward its
     * turning hot.
     */
    const WINDOW = 300;
    /**
     * How long, in seconds, a target stays hot once it is hot, measured
     * from the refusal that made it so. This is much longer than
     * WINDOW because once the web server refuses a hot target its requests
     * never reach PHP, so no further refusal is recorded and the target
     * would look idle while still under attack: measured on a live site,
     * a target cooled every five minutes and cost twenty full PHP requests
     * to become hot again each time. A stale marker costs one refusal of a
     * page that was private in any case.
     */
    const HOT_FOR = 3600;
    /**
     * The arguments whose refusal is counted: requests for a page's history
     * or its source, the two things a private page keeps from a reader
     * who may not edit it, and the two a scraper of a wiki asks for.
     */
    const COUNTED_ARGS = ["history", "source"];
    /**
     * Folder, under the work directory, holding one empty file per hot
     * target, named as markerName names it. The web server's rewrite
     * rules test for the file and refuse a request for a hot target
     * without starting PHP at all, so a scraper costs the site a file
     * existence check. It sits under the work directory because Yioop
     * never writes into its own source tree. The .htaccess looks for it
     * at work_directory beside the source, where the work directory is
     * unless configured elsewhere; a site whose work directory is
     * elsewhere names the folder to the web server with one SetEnvIf line.
     */
    const MARKER_FOLDER = "hot";
    /**
     * targetKey names the thing a refusal is counted toward: the group,
     * the page, and which of history or source was asked for. Two requests
     * for the same page's history from different addresses share a key.
     * @param int $group_id the group the page is in
     * @param string $page the page, by id or name, as the request gave it
     * @param string $arg which of history or source was asked for
     * @return string the key, or an empty string where the argument is not
     *      one that is counted
     */
    public static function targetKey($group_id, $page, $arg)
    {
        if (!in_array($arg, self::COUNTED_ARGS)) {
            return "";
        }
        return intval($group_id) . ":" . $page . ":" . $arg;
    }
    /**
     * keyFromRequest names the target a request is for, so the gate
     * that runs before the router, the refusal that runs after it, and
     * the web server's rewrite rule all arrive at the same key for the
     * same request. The group comes from a /group/N path where there is
     * one, else the query. The page is the page_id from the query where
     * the request carries one, else the name from a /group/N/Page path,
     * else the page_name from the query; the id is preferred because the
     * rewrite rule can read it without knowing the path's shape.
     * @param string $uri the request path and query as the server got it
     * @param array $request the query arguments as decoded so far
     * @return string the key, or an empty string where nothing is counted
     */
    public static function keyFromRequest($uri, $request)
    {
        $arg = $request['arg'] ?? "";
        $group_id = $request['group_id'] ?? 0;
        $path_page = "";
        $path = strtok($uri, "?");
        if (preg_match('@/group/([0-9]+)(?:/([^/?]+))?@', $path, $match)) {
            $group_id = $match[1];
            $path_page = urldecode($match[2] ?? "");
        }
        $page = $request['page_id'] ?? "";
        if ($page === "") {
            $page = ($path_page !== "") ? $path_page :
                ($request['page_name'] ?? "");
        }
        return self::targetKey($group_id, $page, $arg);
    }
    /**
     * recordRefusal adds one refusal for a target at the given time and
     * writes the counts back, dropping any target whose window has passed.
     * @param string $key the target, from targetKey
     * @param int $now the time of the refusal
     * @return void nothing is handed back, the count is written
     */
    public static function recordRefusal($key, $now)
    {
        if ($key === "") {
            return;
        }
        $counts = self::prune(self::read(), $now);
        if (!isset($counts[$key])) {
            $counts[$key] = ["count" => 0, "last" => $now];
        }
        $counts[$key]["count"]++;
        $counts[$key]["last"] = $now;
        if ($counts[$key]["count"] >= self::REFUSALS_TO_BE_HOT &&
            !isset($counts[$key]["hot_since"])) {
            $counts[$key]["hot_since"] = $now;
        }
        self::write($counts);
        self::writeMarkers($counts);
    }
    /**
     * isHot says if a target turned hot, by drawing REFUSALS_TO_BE_HOT
     * refusals within WINDOW seconds, less than HOT_FOR seconds before
     * the given time.
     * @param string $key the target, from targetKey
     * @param int $now the time of the request being judged
     * @return bool true where the target is hot
     */
    public static function isHot($key, $now)
    {
        if ($key === "") {
            return false;
        }
        $counts = self::prune(self::read(), $now);
        return isset($counts[$key]["hot_since"]);
    }
    /**
     * markerName gives the file name the web server tests for a target:
     * the key with its separators turned to hyphens and any character
     * other than a letter, digit, hyphen or underscore dropped, so it is
     * safe as a file name and the rewrite rule can build it from the
     * request with the same substitution.
     * @param string $key the target, from targetKey
     * @return string the marker file name
     */
    public static function markerName($key)
    {
        return preg_replace('/[^A-Za-z0-9_-]/', "",
            str_replace(":", "-", $key));
    }
    /**
     * writeMarkers makes the marker folder hold exactly one file per hot
     * target: a file for each target now hot, and none for any other.
     * recordRefusal calls this after every count so the web server's view
     * follows the counts.
     * @param array $counts targets to their count and last refusal time,
     *      already pruned to the window
     * @return void nothing is handed back, the folder is written
     */
    public static function writeMarkers($counts)
    {
        $folder = C\WORK_DIRECTORY . "/" . self::MARKER_FOLDER;
        if (!file_exists($folder)) {
            mkdir($folder, 0755, true);
        }
        $wanted = [];
        foreach ($counts as $key => $entry) {
            if (isset($entry["hot_since"])) {
                $wanted[self::markerName($key)] = true;
            }
        }
        foreach (glob($folder . "/*") as $present) {
            if (!isset($wanted[basename($present)])) {
                unlink($present);
            }
        }
        foreach ($wanted as $name => $unused) {
            touch($folder . "/" . $name);
        }
    }
    /**
     * prune drops each target that is past its span: a hot target once
     * HOT_FOR seconds have passed since it became hot, any other once
     * WINDOW seconds have passed since its last refusal.
     * @param array $counts targets to their count and last refusal time
     * @param int $now the time to measure age from
     * @return array the targets still within the window
     */
    public static function prune($counts, $now)
    {
        foreach ($counts as $key => $entry) {
            if (isset($entry["hot_since"])) {
                if ($now - $entry["hot_since"] > self::HOT_FOR) {
                    unset($counts[$key]);
                }
            } else if ($now - $entry["last"] > self::WINDOW) {
                unset($counts[$key]);
            }
        }
        return $counts;
    }
    /**
     * read gives the counts held in the file, or none where there is no
     * file yet or it cannot be read.
     * @return array targets to their count and last refusal time
     */
    public static function read()
    {
        $path = C\TEMP_DIR . "/" . self::FILE_NAME;
        if (!file_exists($path)) {
            return [];
        }
        $counts = json_decode(file_get_contents($path), true);
        return is_array($counts) ? $counts : [];
    }
    /**
     * write stores the counts in the file, making the temp directory if
     * it is not there yet.
     * @param array $counts targets to their count and last refusal time
     * @return void nothing is handed back, the file is written
     */
    public static function write($counts)
    {
        if (!file_exists(C\TEMP_DIR)) {
            mkdir(C\TEMP_DIR, 0777, true);
        }
        file_put_contents(C\TEMP_DIR . "/" . self::FILE_NAME,
            json_encode($counts), LOCK_EX);
    }
}