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