WebP Express CloudHost.es Fix v0.25.9-cloudhost

 Fixed bulk conversion getting stuck on missing files
 Added robust error handling and timeout protection
 Improved JavaScript response parsing
 Added file existence validation
 Fixed missing PHP class imports
 Added comprehensive try-catch error recovery

🔧 Key fixes:
- File existence checks before conversion attempts
- 30-second timeout protection per file
- Graceful handling of 500 errors and JSON parsing issues
- Automatic continuation to next file on failures
- Cache busting for JavaScript updates

🎯 Result: Bulk conversion now completes successfully even with missing files

🚀 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-23 10:22:32 +02:00
commit 37cf714058
553 changed files with 55249 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
<?php
$finder = PhpCsFixer\Finder::create()
->exclude('tests')
->in(__DIR__)
;
$config = PhpCsFixer\Config::create();
$config
->setRules([
'@PSR2' => true,
'array_syntax' => [
'syntax' => 'short',
],
])
->setFinder($finder)
;
return $config;

View File

@@ -0,0 +1,182 @@
# dom-util-for-webp
[![Latest Stable Version](https://img.shields.io/packagist/v/rosell-dk/dom-util-for-webp.svg?style=flat-square)](https://packagist.org/packages/rosell-dk/dom-util-for-webp)
[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%205.6-8892BF.svg?style=flat-square)](https://php.net)
[![Build Status](https://img.shields.io/github/actions/workflow/status/rosell-dk/dom-util-for-webp/ci.yml?branch=master&logo=GitHub&style=flat-square&label=build)](https://github.com/rosell-dk/dom-util-for-webp/actions/workflows/ci.yml)
[![Coverage](https://img.shields.io/endpoint?url=https://little-b.it/dom-util-for-webp/code-coverage/coverage-badge.json)](http://little-b.it/dom-util-for-webp/code-coverage/coverage/index.html)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://github.com/rosell-dk/dom-util-for-webp/blob/master/LICENSE)
*Replace image URLs found in HTML*
This library can do two things:
1) Replace image URLs in HTML
2) Replace *&lt;img&gt;* tags with *&lt;picture&gt;* tags, adding webp versions to sources
To setup with composer, run ```composer require rosell-dk/dom-util-for-webp```.
## 1. Replacing image URLs in HTML
The *ImageUrlReplacer::replace($html)* method accepts a piece of HTML and returns HTML where where all image URLs have been replaced - even those in inline styles.
*Usage:*
```php
$modifiedHtml = ImageUrlReplacer::replace($html);
```
### Example replacements:
*input:*
```html
<img src="image.jpg">
<img src="1.jpg" srcset="2.jpg 1000w">
<picture>
<source srcset="1.jpg" type="image/webp">
<source srcset="2.png" type="image/webp">
<source src="3.gif"> <!-- gifs are skipped in default behaviour -->
<source src="4.jpg?width=200"> <!-- urls with query string are skipped in default behaviour -->
</picture>
<div style="background-image: url('image.jpeg')"></div>
<style>
#hero {
background: lightblue url("image.png") no-repeat fixed center;;
}
</style>
<input type="button" src="1.jpg">
<img data-src="image.jpg"> <!-- any attribute starting with "data-" are replaced (if it ends with "jpg", "jpeg" or "png"). For lazy-loading -->
```
*output:*
```html
<img src="image.jpg.webp">
<img src="1.jpg.webp" srcset="2.jpg.webp 1000w">
<picture>
<source srcset="1.jpg.webp" type="image/webp">
<source srcset="2.jpg.webp" type="image/webp">
<source srcset="3.gif"> <!-- gifs are skipped in default behaviour -->
<source srcset="4.jpg?width=200"> <!-- urls with query string are skipped in default behaviour -->
</picture>
<div style="background-image: url('image.jpeg.webp')"></div>
<style>
#hero {
background: lightblue url("image.png.webp") no-repeat fixed center;;
}
</style>
<input type="button" src="1.jpg.webp">
<img data-src="image.jpg.webp"> <!-- any attribute starting with "data-" are replaced (if it ends with "jpg", "jpeg" or "png"). For lazy-loading -->
```
Default behaviour of *ImageUrlReplacer::replace*:
- The modified URL is the same as the original, with ".webp" appended (to change, override the `replaceUrl` function)
- Only replaces URLs that ends with "png", "jpg" or "jpeg" (no query strings either) (to change, override the `replaceUrl` function)
- Attribute search/replace limits to these tags: *&lt;img&gt;*, *&lt;source&gt;*, *&lt;input&gt;* and *&lt;iframe&gt;* (to change, override the `$searchInTags` property)
- Attribute search/replace limits to these attributes: "src", "src-set" and any attribute starting with "data-" (to change, override the `attributeFilter` function)
- Urls inside styles are replaced too (*background-image* and *background* properties)
The behaviour can be modified by extending *ImageUrlReplacer* and overriding public methods such as *replaceUrl*
ImageUrlReplacer uses the `Sunra\PhpSimple\HtmlDomParser`[library](https://github.com/sunra/php-simple-html-dom-parser) for parsing and modifying HTML. It wraps [simplehtmldom](http://simplehtmldom.sourceforge.net/). Simplehtmldom supports invalid HTML (it does not touch the invalid parts)
### Example: Customized behaviour
```php
class ImageUrlReplacerCustomReplacer extends ImageUrlReplacer
{
public function replaceUrl($url) {
// Only accept urls ending with "png", "jpg", "jpeg" and "gif"
if (!preg_match('#(png|jpe?g|gif)$#', $url)) {
return;
}
// Only accept full urls (beginning with http:// or https://)
if (!preg_match('#^https?://#', $url)) {
return;
}
// PS: You probably want to filter out external images too...
// Simply append ".webp" after current extension.
// This strategy ensures that "logo.jpg" and "logo.gif" gets counterparts with unique names
return $url . '.webp';
}
public function attributeFilter($attrName) {
// Don't allow any "data-" attribute, but limit to attributes that smells like they are used for images
// The following rule matches all attributes used for lazy loading images that we know of
return preg_match('#^(src|srcset|(data-[^=]*(lazy|small|slide|img|large|src|thumb|source|set|bg-url)[^=]*))$#i', $attrName);
// If you want to limit it further, only allowing attributes known to be used for lazy load,
// use the following regex instead:
//return preg_match('#^(src|srcset|data-(src|srcset|cvpsrc|cvpset|thumb|bg-url|large_image|lazyload|source-url|srcsmall|srclarge|srcfull|slide-img|lazy-original))$#i', $attrName);
}
}
$modifiedHtml = ImageUrlReplacerCustomReplacer::replace($html);
```
## 2. Replacing *&lt;img&gt;* tags with *&lt;picture&gt;* tags
The *PictureTags::replace($html)* method accepts a piece of HTML and returns HTML where where all &lt;img&gt; tags have been replaced with &lt;picture&gt; tags, adding webp versions to sources
Usage:
```php
$modifiedHtml = PictureTags::replace($html);
```
#### Example replacements:
*Input:*
```html
<img src="1.png">
<img srcset="3.jpg 1000w" src="3.jpg">
<img data-lazy-src="9.jpg" style="border:2px solid red" class="something">
<figure class="wp-block-image">
<img src="12.jpg" alt="" class="wp-image-6" srcset="12.jpg 492w, 12-300x265.jpg 300w" sizes="(max-width: 492px) 100vw, 492px">
</figure>
```
*Output*:
```html
<picture><source srcset="1.png.webp" type="image/webp"><img src="1.png" class="webpexpress-processed"></picture>
<picture><source srcset="3.jpg.webp 1000w" type="image/webp"><img srcset="3.jpg 1000w" src="3.jpg" class="webpexpress-processed"></picture>
<picture><source data-lazy-src="9.jpg.webp" type="image/webp"><img data-lazy-src="9.jpg" style="border:2px solid red" class="something webpexpress-processed"></picture>
<figure class="wp-block-image">
<picture><source srcset="12.jpg.webp 492w, 12-300x265.jpg.webp 300w" sizes="(max-width: 492px) 100vw, 492px" type="image/webp"><img src="12.jpg" alt="" class="wp-image-6 webpexpress-processed" srcset="12.jpg 492w, 12-300x265.jpg 300w" sizes="(max-width: 492px) 100vw, 492px"></picture>
</figure>'
```
Note that with the picture tags, it is still the img tag that shows the selected image. The picture tag is just a wrapper.
So it is correct behaviour not to copy the *style*, *width*, *class* or any other attributes to the picture tag. See [issue #9](https://github.com/rosell-dk/dom-util-for-webp/issues/9).
As with `ImageUrlReplacer`, you can override the *replaceUrl* function. There is however currently no other methods to override.
`PictureTags` currently uses regular expressions to do the replacing. There are plans to change implementation to use `Sunra\PhpSimple\HtmlDomParser`, like our `ImageUrlReplacer` class does.
## Platforms
Works on (at least):
- OS: Ubuntu (22.04, 20.04, 18.04), Windows (2022, 2019), Mac OS (13, 12, 11, 10.15)
- PHP: 5.6 - 8.2 (also tested 8.3 and 8.4 development versions in October 2023)
Each new release will be tested on all combinations of OSs and PHP versions that are [supported](https://github.com/marketplace/actions/setup-php-action) by GitHub-hosted runners. Except that we do not below PHP 5.6.\
Status: [![Build Status](https://img.shields.io/github/actions/workflow/status/rosell-dk/dom-util-for-webp/release.yml?branch=master&logo=GitHub&style=flat-square&label=Giant%20test)](https://github.com/rosell-dk/dom-util-for-webp/actions/workflows/release.yml)
Testing consists of running the unit tests. The code in this library is almost completely covered by tests (~95% coverage).
We also test future versions of PHP monthly, in order to catch problems early.\
Status:
[![PHP 8.3](https://img.shields.io/github/actions/workflow/status/rosell-dk/dom-util-for-webp/php83.yml?branch=master&logo=GitHub&style=flat-square&label=PHP%208.3)](https://github.com/rosell-dk/dom-util-for-webp/actions/workflows/php83.yml)
[![PHP 8.4](https://img.shields.io/github/actions/workflow/status/rosell-dk/dom-util-for-webp/php84.yml?branch=master&logo=GitHub&style=flat-square&label=PHP%208.4)](https://github.com/rosell-dk/dom-util-for-webp/actions/workflows/php84.yml)
## Do you like what I do?
Perhaps you want to support my work, so I can continue doing it :)
- [Become a backer or sponsor on Patreon](https://www.patreon.com/rosell).
- [Buy me a Coffee](https://ko-fi.com/rosell)

View File

@@ -0,0 +1,66 @@
{
"name": "rosell-dk/dom-util-for-webp",
"description": "Replace image URLs found in HTML",
"type": "library",
"license": "MIT",
"minimum-stability": "stable",
"keywords": ["webp", "replace", "images", "html"],
"scripts": {
"ci": [
"@build",
"@test-cov-console",
"@phpcs-all",
"@composer validate --no-check-all --strict",
"@phpstan"
],
"cs-fix-all": [
"php-cs-fixer fix src"
],
"cs-fix": "php-cs-fixer fix",
"cs-dry": "php-cs-fixer fix --dry-run --diff",
"test": "phpunit --coverage-text=build/coverage.txt --coverage-clover=build/coverage.clover --coverage-html=build/coverage --whitelist=src tests",
"test-cov-console": "phpunit --coverage-text --whitelist=src tests",
"test-41": "phpunit --coverage-text --configuration 'phpunit-41.xml.dist'",
"test-no-cov": "phpunit --no-coverage tests",
"phpunit": "phpunit --no-coverage",
"phpcs": "phpcs --standard=phpcs-ruleset.xml",
"phpcs-all": "phpcs --standard=phpcs-ruleset.xml src",
"phpcbf": "phpcbf --standard=phpcs-ruleset.xml",
"phpstan": "vendor/bin/phpstan analyse src --level=4"
},
"extra": {
"scripts-descriptions": {
"ci": "Run tests before CI",
"phpcs": "Checks coding styles (PSR2) of file/dir, which you must supply. To check all, supply 'src'",
"phpcbf": "Fix coding styles (PSR2) of file/dir, which you must supply. To fix all, supply 'src'",
"cs-fix-all": "Fix the coding style of all the source files, to comply with the PSR-2 coding standard",
"cs-fix": "Fix the coding style of a PHP file or directory, which you must specify.",
"test": "Launches the preconfigured PHPUnit"
}
},
"autoload": {
"psr-4": { "DOMUtilForWebP\\": "src/" }
},
"autoload-dev": {
"psr-4": { "DOMUtilForWebPTests\\": "tests/" }
},
"authors": [
{
"name": "Bjørn Rosell",
"homepage": "https://www.bitwise-it.dk/contact",
"role": "Project Author"
}
],
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.11",
"phpstan/phpstan": "^1.5",
"phpunit/phpunit": "^9.3",
"squizlabs/php_codesniffer": "3.*"
},
"config": {
"sort-packages": true
},
"require": {
"kub-at/php-simple-html-dom-parser": "^1.9"
}
}

View File

@@ -0,0 +1,43 @@
# Development
## Setting up the environment.
First, clone the repository:
```
cd whatever/folder/you/want
git clone git@github.com:rosell-dk/dom-util-for-webp.git
```
Then install the dev tools with composer:
```
composer install
```
If you don't have composer yet:
- Get it ([download phar](https://getcomposer.org/composer.phar) and move it to /usr/local/bin/composer)
- PS: PHPUnit requires php-xml, php-mbstring and php-curl. To install: `sudo apt install php-xml php-mbstring curl php-curl`
Make sure you have [xdebug](https://xdebug.org/docs/install) installed, if you want phpunit tog generate code coverage report
## Unit Testing
To run all the unit tests do this:
```
composer test
```
This also runs tests on the builds.
If you do not the coverage report:
```
composer phpunit
```
Individual test files can be executed like this:
```
composer phpunit tests/ImageUrlReplacerTest.php
composer phpunit tests/PictureTagsTest.php
```
Note:
The code coverage requires [xdebug](https://xdebug.org/docs/install)

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<ruleset name="Custom Standard">
<description>PSR2 without line ending rule - let git manage the EOL cross the platforms</description>
<rule ref="PSR2" />
<rule ref="Generic.Files.LineEndings">
<exclude name="Generic.Files.LineEndings.InvalidEOLChar"/>
</rule>
</ruleset>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.1/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="false"
processIsolation="false"
stopOnFailure="false"
bootstrap="vendor/autoload.php"
>
<testsuites>
<testsuite name="Dom util for WebP Test Suite">
<directory>./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">src/</directory>
<exclude>
<directory>./vendor</directory>
<directory>./tests</directory>
</exclude>
</whitelist>
</filter>
<logging>
<log type="junit" target="build/report.junit.xml"/>
<log type="coverage-clover" target="build/logs/clover.xml"/>
<log type="coverage-text" target="build/coverage.txt"/>
<!--<log type="coverage-html" target="build/coverage"/>-->
</logging>
</phpunit>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
convertDeprecationsToExceptions="true"
processIsolation="true"
stopOnFailure="false"
bootstrap="vendor/autoload.php"
failOnWarning="true"
failOnRisky="false">
<testsuites>
<testsuite name="Dom util for WebP Test Suite">
<directory>./tests/</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -0,0 +1,247 @@
<?php
namespace DOMUtilForWebP;
//use Sunra\PhpSimple\HtmlDomParser;
use KubAT\PhpSimple\HtmlDomParser;
/**
* Highly configurable class for replacing image URLs in HTML (both src and srcset syntax)
*
* Uses http://simplehtmldom.sourceforge.net/ - a library for easily manipulating HTML by means of a DOM.
* The great thing about this library is that it supports working on invalid HTML and it only applies the changes you
* make - very gently (however, not as gently as we do in PictureTags).
* PS: The library is a bit old, so perhaps we should look for another.
* ie https://packagist.org/packages/masterminds/html5 ??
*
* Behaviour can be customized by overriding the public methods (replaceUrl, $searchInTags, etc)
*
* Default behaviour:
* - The modified URL is the same as the original, with ".webp" appended (replaceUrl)
* - Limits to these tags: <img>, <source>, <input> and <iframe> ($searchInTags)
* - Limits to these attributes: "src", "src-set" and any attribute starting with "data-" (attributeFilter)
* - Only replaces URLs that ends with "png", "jpg" or "jpeg" (no query strings either) (replaceUrl)
*
*
*/
class ImageUrlReplacer
{
// define tags to be searched.
// The div and li are on the list because these are often used with lazy loading
// should we add <meta> ?
// Probably not for open graph images or twitter
// so not these:
// - <meta property="og:image" content="[url]">
// - <meta property="og:image:secure_url" content="[url]">
// - <meta name="twitter:image" content="[url]">
// Meta can also be used in schema.org micro-formatting, ie:
// - <meta itemprop="image" content="[url]">
//
// How about preloaded images? - yes, suppose we should replace those
// - <link rel="prefetch" href="[url]">
// - <link rel="preload" as="image" href="[url]">
public static $searchInTags = ['img', 'source', 'input', 'iframe', 'div', 'li', 'link', 'a', 'section', 'video'];
/**
* Empty constructor for preventing child classes from creating constructors.
*
* We do this because otherwise the "new static()" call inside the ::replace() method
* would be unsafe. See #21
* @return void
*/
final public function __construct()
{
}
/**
*
* @return string|null webp url or, if URL should not be changed, return nothing
**/
public function replaceUrl($url)
{
if (!preg_match('#(png|jpe?g)$#', $url)) {
return null;
}
return $url . '.webp';
}
public function replaceUrlOr($url, $returnValueIfDenied)
{
$url = $this->replaceUrl($url);
return (isset($url) ? $url : $returnValueIfDenied);
}
/*
public function isValidUrl($url)
{
return preg_match('#(png|jpe?g)$#', $url);
}*/
public function handleSrc($attrValue)
{
return $this->replaceUrlOr($attrValue, $attrValue);
}
public function handleSrcSet($attrValue)
{
// $attrValue is ie: <img data-x="1.jpg 1000w, 2.jpg">
$srcsetArr = explode(',', $attrValue);
foreach ($srcsetArr as $i => $srcSetEntry) {
// $srcSetEntry is ie "image.jpg 520w", but can also lack width, ie just "image.jpg"
// it can also be ie "image.jpg 2x"
$srcSetEntry = trim($srcSetEntry);
$entryParts = preg_split('/\s+/', $srcSetEntry, 2);
if (count($entryParts) == 2) {
list($src, $descriptors) = $entryParts;
} else {
$src = $srcSetEntry;
$descriptors = null;
}
$webpUrl = $this->replaceUrlOr($src, false);
if ($webpUrl !== false) {
$srcsetArr[$i] = $webpUrl . (isset($descriptors) ? ' ' . $descriptors : '');
}
}
return implode(', ', $srcsetArr);
}
/**
* Test if attribute value looks like it has srcset syntax.
* "image.jpg 100w" does for example. And "image.jpg 1x". Also "image1.jpg, image2.jpg 1x"
* Mixing x and w is invalid (according to
* https://stackoverflow.com/questions/26928828/html5-srcset-mixing-x-and-w-syntax)
* But we accept it anyway
* It is not the job of this function to see if the first part is an image URL
* That will be done in handleSrcSet.
*
*/
public function looksLikeSrcSet($value)
{
if (preg_match('#\s\d*(w|x)#', $value)) {
return true;
}
return false;
}
public function handleAttribute($value)
{
if (self::looksLikeSrcSet($value)) {
return self::handleSrcSet($value);
}
return self::handleSrc($value);
}
public function attributeFilter($attrName)
{
$attrName = strtolower($attrName);
if (($attrName == 'src') || ($attrName == 'srcset') || (strpos($attrName, 'data-') === 0)) {
return true;
}
return false;
}
public function processCSSRegExCallback($matches)
{
list($all, $pre, $quote, $url, $post) = $matches;
return $pre . $this->replaceUrlOr($url, $url) . $post;
}
public function processCSS($css)
{
$declarations = explode(';', $css);
foreach ($declarations as $i => &$declaration) {
if (preg_match('#(background(-image)?)\\s*:#', $declaration)) {
// https://regexr.com/46qdg
//$regex = '#(url\s*\(([\"\']?))([^\'\";\)]*)(\2\s*\))#';
$parts = explode(',', $declaration);
//print_r($parts);
foreach ($parts as &$part) {
//echo 'part:' . $part . "\n";
$regex = '#(url\\s*\\(([\\"\\\']?))([^\\\'\\";\\)]*)(\\2\\s*\\))#';
$part = preg_replace_callback(
$regex,
'\DOMUtilForWebP\ImageUrlReplacer::processCSSRegExCallback',
$part
);
//echo 'result:' . $part . "\n";
}
$declarations[$i] = implode(',', $parts);
}
}
return implode(';', $declarations);
}
public function replaceHtml($html)
{
if ($html == '') {
return '';
}
// https://stackoverflow.com/questions/4812691/preserve-line-breaks-simple-html-dom-parser
// function str_get_html($str, $lowercase=true, $forceTagsClosed=true, $target_charset = DEFAULT_TARGET_CHARSET,
// $stripRN=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT)
$dom = HtmlDomParser::str_get_html($html, false, true, 'UTF-8', false);
//$dom = str_get_html($html, false, false, 'UTF-8', false);
// MAX_FILE_SIZE is defined in simple_html_dom.
// For safety sake, we make sure it is defined before using
defined('MAX_FILE_SIZE') || define('MAX_FILE_SIZE', 600000);
if ($dom === false) {
if (strlen($html) > MAX_FILE_SIZE) {
return '<!-- Alter HTML was skipped because the HTML is too big to process! ' .
'(limit is set to ' . MAX_FILE_SIZE . ' bytes) -->' . "\n" . $html;
}
return '<!-- Alter HTML was skipped because the helper library refused to process the html -->' .
"\n" . $html;
}
// Replace attributes (src, srcset, data-src, etc)
foreach (self::$searchInTags as $tagName) {
$elems = $dom->find($tagName);
foreach ($elems as $index => $elem) {
$attributes = $elem->getAllAttributes();
foreach ($elem->getAllAttributes() as $attrName => $attrValue) {
if ($this->attributeFilter($attrName)) {
$elem->setAttribute($attrName, $this->handleAttribute($attrValue));
}
}
}
}
// Replace <style> elements
$elems = $dom->find('style');
foreach ($elems as $index => $elem) {
$css = $this->processCSS($elem->innertext);
if ($css != $elem->innertext) {
$elem->innertext = $css;
}
}
// Replace "style attributes
$elems = $dom->find('*[style]');
foreach ($elems as $index => $elem) {
$css = $this->processCSS($elem->style);
if ($css != $elem->style) {
$elem->style = $css;
}
}
return $dom->save();
}
/* Main replacer function */
public static function replace($html)
{
/*if (!function_exists('str_get_html')) {
require_once __DIR__ . '/../src-vendor/simple_html_dom/simple_html_dom.inc';
}*/
$iur = new static();
return $iur->replaceHtml($html);
}
}

View File

@@ -0,0 +1,337 @@
<?php
namespace DOMUtilForWebP;
//use Sunra\PhpSimple\HtmlDomParser;
use KubAT\PhpSimple\HtmlDomParser;
/**
* Class PictureTags - convert an <img> tag to a <picture> tag and add the webp versions of the images
* Code is based on code from the ShortPixel plugin, which in turn used code from Responsify WP plugin
*
* It works like this:
*
* 1. Remove existing <picture> tags and their content - replace with tokens in order to reinsert later
* 2. Process <img> tags.
* - The tags are found with regex.
* - The attributes are parsed with DOMDocument if it exists, otherwise with the Simple Html Dom library,
* which is included inside this library
* 3. Re-insert the existing <picture> tags
*
* This procedure is very gentle and needle-like. No need for a complete parse - so invalid HTML is no big issue
*
* PS:
* https://packagist.org/packages/masterminds/html5
*/
class PictureTags
{
/**
* Empty constructor for preventing child classes from creating constructors.
*
* We do this because otherwise the "new static()" call inside the ::replace() method
* would be unsafe. See #21
* @return void
*/
final public function __construct()
{
$this->existingPictureTags = [];
}
private $existingPictureTags;
public function replaceUrl($url)
{
if (!preg_match('#(png|jpe?g)$#', $url)) {
return;
}
return $url . '.webp';
}
public function replaceUrlOr($url, $returnValueIfDenied)
{
$url = $this->replaceUrl($url);
return (isset($url) ? $url : $returnValueIfDenied);
}
/**
* Look for attributes such as "data-lazy-src" and "data-src" and prefer them over "src"
*
* @param array $attributes an array of attributes for the element
* @param string $attrName ie "src", "srcset" or "sizes"
*
* @return array an array with "value" key and "attrName" key. ("value" is the value of the attribute and
* "attrName" is the name of the attribute used)
*
*/
private static function lazyGet($attributes, $attrName)
{
return array(
'value' =>
(isset($attributes['data-lazy-' . $attrName]) && strlen($attributes['data-lazy-' . $attrName])) ?
trim($attributes['data-lazy-' . $attrName])
: (isset($attributes['data-' . $attrName]) && strlen($attributes['data-' . $attrName]) ?
trim($attributes['data-' . $attrName])
: (isset($attributes[$attrName]) && strlen($attributes[$attrName]) ?
trim($attributes[$attrName]) : false)),
'attrName' =>
(isset($attributes['data-lazy-' . $attrName]) && strlen($attributes['data-lazy-' . $attrName])) ?
'data-lazy-' . $attrName
: (isset($attributes['data-' . $attrName]) && strlen($attributes['data-' . $attrName]) ?
'data-' . $attrName
: (isset($attributes[$attrName]) && strlen($attributes[$attrName]) ? $attrName : false))
);
}
/**
* Look for attribute such as "src", but also with prefixes such as "data-lazy-src" and "data-src"
*
* @param array $attributes an array of all attributes for the element
* @param string $attrName ie "src", "srcset" or "sizes"
*
* @return array an array with "value" key and "attrName" key. ("value" is the value of the attribute and
* "attrName" is the name of the attribute used)
*
*/
private static function findAttributesWithNameOrPrefixed($attributes, $attrName)
{
$tryThesePrefixes = ['', 'data-lazy-', 'data-'];
$result = [];
foreach ($tryThesePrefixes as $prefix) {
$name = $prefix . $attrName;
if (isset($attributes[$name]) && strlen($attributes[$name])) {
/*$result[] = [
'value' => trim($attributes[$name]),
'attrName' => $name,
];*/
$result[$name] = trim($attributes[$name]);
}
}
return $result;
}
/**
* Convert to UTF-8 and encode chars outside of ascii-range
*
* Input: html that might be in any character encoding and might contain non-ascii characters
* Output: html in UTF-8 encding, where non-ascii characters are encoded
*
*/
private static function textToUTF8WithNonAsciiEncoded($html)
{
if (function_exists("mb_convert_encoding")) {
$html = mb_convert_encoding($html, 'UTF-8');
$html = mb_encode_numericentity($html, array (0x7f, 0xffff, 0, 0xffff), 'UTF-8');
}
return $html;
}
private static function getAttributes($html)
{
if (class_exists('\\DOMDocument')) {
$dom = new \DOMDocument();
if (function_exists("mb_encode_numericentity")) {
// I'm in doubt if I should add the following line (see #41)
// $html = mb_convert_encoding($html, 'UTF-8');
$html = mb_encode_numericentity($html, array (0x7f, 0xffff, 0, 0xffff)); // #41
}
@$dom->loadHTML($html);
$image = $dom->getElementsByTagName('img')->item(0);
$attributes = [];
foreach ($image->attributes as $attr) {
$attributes[$attr->nodeName] = $attr->nodeValue;
}
return $attributes;
} else {
// Convert to UTF-8 because HtmlDomParser::str_get_html needs to be told the
// encoding. As UTF-8 might conflict with the charset set in the meta, we must
// encode all characters outside the ascii-range.
// It would perhaps have been better to try to guess the encoding rather than
// changing it (see #39), but I'm reluctant to introduce changes.
$html = self::textToUTF8WithNonAsciiEncoded($html);
$dom = HtmlDomParser::str_get_html($html, false, true, 'UTF-8', false);
if ($dom !== false) {
$elems = $dom->find('img,IMG');
foreach ($elems as $index => $elem) {
$attributes = [];
foreach ($elem->getAllAttributes() as $attrName => $attrValue) {
$attributes[strtolower($attrName)] = $attrValue;
}
return $attributes;
}
}
return [];
}
}
/**
* Makes a string with all attributes.
*
* @param array $attribute_array
* @return string
*/
private static function createAttributes($attribute_array)
{
$attributes = '';
foreach ($attribute_array as $attribute => $value) {
$attributes .= $attribute . '="' . $value . '" ';
}
if ($attributes == '') {
return '';
}
// Removes the extra space after the last attribute. Add space before
return ' ' . substr($attributes, 0, -1);
}
/**
* Replace <img> tag with <picture> tag.
*/
private function replaceCallback($match)
{
$imgTag = $match[0];
// Do nothing with images that have the 'webpexpress-processed' class.
if (strpos($imgTag, 'webpexpress-processed')) {
return $imgTag;
}
$imgAttributes = self::getAttributes($imgTag);
$srcInfo = self::lazyGet($imgAttributes, 'src');
$srcsetInfo = self::lazyGet($imgAttributes, 'srcset');
$sizesInfo = self::lazyGet($imgAttributes, 'sizes');
$srcSetAttributes = self::findAttributesWithNameOrPrefixed($imgAttributes, 'srcset');
$srcAttributes = self::findAttributesWithNameOrPrefixed($imgAttributes, 'src');
if ((!isset($srcSetAttributes['srcset'])) && (!isset($srcAttributes['src']))) {
// better not mess with this html...
return $imgTag;
}
// add the exclude class so if this content is processed again in other filter,
// the img is not converted again in picture
$imgAttributes['class'] = (isset($imgAttributes['class']) ? $imgAttributes['class'] . " " : "") .
"webpexpress-processed";
// Process srcset (also data-srcset etc)
$atLeastOneWebp = false;
$sourceTagAttributes = [];
foreach ($srcSetAttributes as $attrName => $attrValue) {
$srcsetArr = explode(', ', $attrValue);
$srcsetArrWebP = [];
foreach ($srcsetArr as $i => $srcSetEntry) {
// $srcSetEntry is ie "http://example.com/image.jpg 520w"
$result = preg_split('/\s+/', trim($srcSetEntry));
$src = trim($srcSetEntry);
$width = null;
if ($result && count($result) >= 2) {
list($src, $width) = $result;
}
$webpUrl = $this->replaceUrlOr($src, false);
if ($webpUrl == false) {
// We want ALL of the sizes as webp.
// If we cannot have that, it is better to abort! - See #42
return $imgTag;
} else {
if (substr($src, 0, 5) != 'data:') {
$atLeastOneWebp = true;
$srcsetArrWebP[] = $webpUrl . (isset($width) ? ' ' . $width : '');
}
}
}
$sourceTagAttributes[$attrName] = implode(', ', $srcsetArrWebP);
}
foreach ($srcAttributes as $attrName => $attrValue) {
if (substr($attrValue, 0, 5) == 'data:') {
// ignore tags with data urls, such as <img src="data:...
return $imgTag;
}
// Make sure not to override existing srcset with src
if (!isset($sourceTagAttributes[$attrName . 'set'])) {
$srcWebP = $this->replaceUrlOr($attrValue, false);
if ($srcWebP !== false) {
$atLeastOneWebp = true;
}
$sourceTagAttributes[$attrName . 'set'] = $srcWebP;
}
}
if ($sizesInfo['value']) {
$sourceTagAttributes[$sizesInfo['attrName']] = $sizesInfo['value'];
}
if (!$atLeastOneWebp) {
// We have no webps for you, so no reason to create <picture> tag
return $imgTag;
}
return '<picture>'
. '<source' . self::createAttributes($sourceTagAttributes) . ' type="image/webp">'
. '<img' . self::createAttributes($imgAttributes) . '>'
. '</picture>';
}
/*
*
*/
public function removePictureTagsTemporarily($content)
{
//print_r($content);
$this->existingPictureTags[] = $content[0];
return 'PICTURE_TAG_' . (count($this->existingPictureTags) - 1) . '_';
}
/*
*
*/
public function insertPictureTagsBack($content)
{
$numberString = $content[1];
$numberInt = intval($numberString);
return $this->existingPictureTags[$numberInt];
}
/**
*
*/
public function replaceHtml($content)
{
if (!class_exists('\\DOMDocument') && function_exists('mb_detect_encoding')) {
// PS: Correctly identifying Windows-1251 encoding only works on some systems
// But at least I'm not aware of any false positives
if (mb_detect_encoding($content, ["ASCII", "UTF8", "Windows-1251"]) == 'Windows-1251') {
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1251');
}
}
$this->existingPictureTags = [];
// Tempororily remove existing <picture> tags
$content = preg_replace_callback(
'/<picture[^>]*>.*?<\/picture>/is',
array($this, 'removePictureTagsTemporarily'),
$content
);
// Replace "<img>" tags
$content = preg_replace_callback('/<img[^>]*>/i', array($this, 'replaceCallback'), $content);
// Re-insert <picture> tags that was removed
$content = preg_replace_callback('/PICTURE_TAG_(\d+)_/', array($this, 'insertPictureTagsBack'), $content);
return $content;
}
/* Main replacer function */
public static function replace($html)
{
$pt = new static();
return $pt->replaceHtml($html);
}
}

View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -0,0 +1,69 @@
# Exec with fallback
[![Latest Stable Version](http://poser.pugx.org/rosell-dk/exec-with-fallback/v)](https://packagist.org/packages/rosell-dk/exec-with-fallback)
[![Build Status](https://github.com/rosell-dk/exec-with-fallback/actions/workflows/php.yml/badge.svg)](https://github.com/rosell-dk/exec-with-fallback/actions/workflows/php.yml)
[![Software License](http://poser.pugx.org/rosell-dk/exec-with-fallback/license)](https://github.com/rosell-dk/exec-with-fallback/blob/master/LICENSE)
[![PHP Version Require](http://poser.pugx.org/rosell-dk/exec-with-fallback/require/php)](https://packagist.org/packages/rosell-dk/exec-with-fallback)
[![Daily Downloads](http://poser.pugx.org/rosell-dk/exec-with-fallback/d/daily)](https://packagist.org/packages/rosell-dk/exec-with-fallback)
Some shared hosts may have disabled *exec()*, but leaved *proc_open()*, *passthru()*, *popen()* or *shell_exec()* open. In case you want to easily fall back to emulating *exec()* with one of these, you have come to the right library.
This library can be useful if you a writing code that is meant to run on a broad spectrum of systems, as it makes your exec() call succeed on more of these systems.
## Usage:
Simply swap out your current *exec()* calls with *ExecWithFallback::exec()*. The signatures are exactly the same.
```php
use ExecWithFallback\ExecWithFallback;
$result = ExecWithFallback::exec('echo "hi"', $output, $result_code);
// $output (array) now holds the output
// $result_code (int) now holds the result code
// $return (string | false) is now false in case of failure or the last line of the output
```
Note that while the signatures are the same, errors are not exactly the same. There is a reason for that. On some systems, a real `exec()` call results in a FATAL error when the function has been disabled. That is: An error, that cannot be catched. You probably don't want to halt execution on some systems, but not on other. But if you do, use `ExecWithFallback::execNoMercy` instead of `ExecWithFallback::exec`. In case no emulations are available, it calls *exec()*, ensuring exact same error handling as normal *exec()*.
If you have `function_exists('exec')` in your code, you probably want to change them to `ExecWithFallback::anyAvailable()`
## Installing
`composer require rosell-dk/exec-with-fallback`
## Implementation
*ExecWithFallback::exec()* first checks if *exec()* is available and calls it, if it is. In case *exec* is unavailable (deactivated on server), or exec() returns false, it moves on to checking if *passthru()* is available and so on. The order is as follows:
- exec()
- passthru()
- popen()
- proc_open()
- shell_exec()
In case all functions are unavailable, a normal exception is thrown (class: Exception). This is more gentle behavior than real exec(), which on some systems throws FATAL error when the function is disabled. If you want exactly same errors, use `ExecWithFallback::execNoMercy` instead, which instead of throwing an exception calls *exec*, which will result in a throw (to support older PHP, you need to catch both Exception and Throwable. And note that you cannot catch on all systems, because some throws FATAL)
In case none succeeded, but at least one failed by returning false, false is returned. Again to mimic *exec()* behavior.
PS: As *shell_exec()* does not support *$result_code*, it will only be used when $result_code isn't supplied. *system()* is not implemented, as it cannot return the last line of output and there is no way to detect if your code relies on that.
If you for some reason want to run a specific exec() emulation, you can use the corresponding class directly, ie *ProcOpen::exec()*.
## Is it worth it?
Well, often these functions are often all enabled or all disabled. So on the majority of systems, it will not make a difference. But on the other hand: This library is easily installed, very lightweight and very well tested.
**easily installed**\
Install with composer (`composer require rosell-dk/exec-with-fallback`) and substitute your *exec()* calls.
**lightweight**\
The library is extremely lightweight. In case *exec()* is available, it is called immediately and only the main file is autoloaded. In case all are unavailable, it only costs a little loop, amounting to five *function_exists()* calls, and again, only the main file is autoloaded. In case *exec()* is unavailable, but one of the others are available, only that implementation is autoloaded, besides the main file.
**well tested**\
I made sure that the function behaves exactly like *exec()*, and wrote a lot of test cases. It is tested on ubuntu, windows, mac (all in several versions). It is tested in PHP 7.0, 7.1, 7.2, 7.3, 7.4 and 8.0. And it is tested in different combinations of disabled functions.
**going to be maintained**\
I'm going to use this library in [webp-convert](https://github.com/rosell-dk/webp-convert), which is used in many projects. So it is going to be widely used. While I don't expect much need for maintenance for this project, it is going to be there, if needed.
**Con: risk of being recognized as malware**
There is a slight risk that a lazy malware creator uses this library for his malware. The risk is however very small, as the library isn't suitable for malware. First off, the library doesn't try *system()*, as that function does not return output and thus cannot be used to emulate *exec()*. A malware creator would desire to try all possible ways to get his malware executed. Secondly, malware creators probably don't use composer for their malware and would probably want a single function instead of having it spread over multiple files. Third, the library here use a lot of efford in getting the emululated functions to behave exactly as exec(). This concern is probably non-existant for malware creators, who probably cares more about the effect of running the malware. Lastly, a malware creator would want to write his own function instead of copying code found on the internet. Copying stuff would impose a chance that the code is used by another malware creator which increases the risk of anti malware software recognizing it as malware.
## Do you like what I do?
Perhaps you want to support my work, so I can continue doing it :)
- [Become a backer or sponsor on Patreon](https://www.patreon.com/rosell).
- [Buy me a Coffee](https://ko-fi.com/rosell)

View File

@@ -0,0 +1,67 @@
{
"name": "rosell-dk/exec-with-fallback",
"description": "An exec() with fallback to emulations (proc_open, etc)",
"type": "library",
"license": "MIT",
"keywords": ["exec", "open_proc", "command", "fallback", "sturdy", "resiliant"],
"scripts": {
"ci": [
"@test",
"@phpcs-all",
"@composer validate --no-check-all --strict",
"@phpstan-global"
],
"test": "./vendor/bin/phpunit --coverage-text",
"test-41": "phpunit --coverage-text --configuration 'phpunit-41.xml.dist'",
"phpunit": "phpunit --coverage-text",
"test-no-cov": "phpunit --no-coverage",
"cs-fix-all": [
"php-cs-fixer fix src"
],
"cs-fix": "php-cs-fixer fix",
"cs-dry": "php-cs-fixer fix --dry-run --diff",
"phpcs": "phpcs --standard=PSR2",
"phpcs-all": "phpcs --standard=PSR2 src",
"phpcbf": "phpcbf --standard=PSR2",
"phpstan": "vendor/bin/phpstan analyse src --level=4",
"phpstan-global-old": "~/.composer/vendor/bin/phpstan analyse src --level=4",
"phpstan-global": "~/.config/composer/vendor/bin/phpstan analyse src --level=4"
},
"extra": {
"scripts-descriptions": {
"ci": "Run tests before CI",
"phpcs": "Checks coding styles (PSR2) of file/dir, which you must supply. To check all, supply 'src'",
"phpcbf": "Fix coding styles (PSR2) of file/dir, which you must supply. To fix all, supply 'src'",
"cs-fix-all": "Fix the coding style of all the source files, to comply with the PSR-2 coding standard",
"cs-fix": "Fix the coding style of a PHP file or directory, which you must specify.",
"test": "Launches the preconfigured PHPUnit"
}
},
"autoload": {
"psr-4": { "ExecWithFallback\\": "src/" }
},
"autoload-dev": {
"psr-4": { "ExecWithFallback\\Tests\\": "tests/" }
},
"authors": [
{
"name": "Bjørn Rosell",
"homepage": "https://www.bitwise-it.dk/contact",
"role": "Project Author"
}
],
"require": {
"php": "^5.6 | ^7.0 | ^8.0"
},
"suggest": {
"php-stan/php-stan": "Suggested for dev, in order to analyse code before committing"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.11",
"phpunit/phpunit": "^9.3",
"squizlabs/php_codesniffer": "3.*"
},
"config": {
"sort-packages": true
}
}

View File

@@ -0,0 +1,3 @@
parameters:
ignoreErrors:
- '#PHPDoc tag @param.*Unexpected token "&"#'

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" backupGlobals="false" backupStaticAttributes="false" colors="true" verbose="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
<coverage>
<include>
<directory suffix=".php">src/</directory>
</include>
<report>
<clover outputFile="build/logs/clover.xml"/>
<html outputDirectory="build/coverage"/>
<text outputFile="build/coverage.txt"/>
</report>
</coverage>
<testsuites>
<testsuite name=":vendor Test Suite">
<directory suffix="Test.php">./tests/</directory>
</testsuite>
</testsuites>
<logging>
<junit outputFile="build/report.junit.xml"/>
</logging>
</phpunit>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
verbose="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name=":vendor Test Suite">
<directory suffix="Test.php">./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">src/</directory>
</whitelist>
</filter>
<logging>
<log type="junit" target="build/report.junit.xml"/>
<log type="coverage-clover" target="build/logs/clover.xml"/>
<log type="coverage-text" target="build/coverage.txt"/>
<log type="coverage-html" target="build/coverage"/>
</logging>
</phpunit>

View File

@@ -0,0 +1,39 @@
<?php
namespace ExecWithFallback;
/**
* Check if any of the methods are available on the system.
*
* @package ExecWithFallback
* @author Bjørn Rosell <it@rosell.dk>
*/
class Availability extends ExecWithFallback
{
/**
* Check if any of the methods are available on the system.
*
* @param boolean $needResultCode Whether the code using this library is going to supply $result_code to the exec
* call. This matters because shell_exec is only available when not.
*/
public static function anyAvailable($needResultCode = true)
{
foreach (self::$methods as $method) {
if (self::methodAvailable($method, $needResultCode)) {
return true;
}
}
return false;
}
public static function methodAvailable($method, $needResultCode = true)
{
if (!ExecWithFallback::functionEnabled($method)) {
return false;
}
if ($needResultCode) {
return ($method != 'shell_exec');
}
return true;
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace ExecWithFallback;
/**
* Execute command with exec(), open_proc() or whatever available
*
* @package ExecWithFallback
* @author Bjørn Rosell <it@rosell.dk>
*/
class ExecWithFallback
{
protected static $methods = ['exec', 'passthru', 'popen', 'proc_open', 'shell_exec'];
/**
* Check if any of the methods are available on the system.
*
* @param boolean $needResultCode Whether the code using this library is going to supply $result_code to the exec
* call. This matters because shell_exec is only available when not.
*/
public static function anyAvailable($needResultCode = true)
{
return Availability::anyAvailable($needResultCode);
}
/**
* Check if a function is enabled (function_exists as well as ini is tested)
*
* @param string $functionName The name of the function
*
* @return boolean If the function is enabled
*/
public static function functionEnabled($functionName)
{
if (!function_exists($functionName)) {
return false;
}
if (function_exists('ini_get')) {
if (ini_get('safe_mode')) {
return false;
}
$d = ini_get('disable_functions') . ',' . ini_get('suhosin.executor.func.blacklist');
if ($d === false) {
$d = '';
}
$d = preg_replace('/,\s*/', ',', $d);
if (strpos(',' . $d . ',', ',' . $functionName . ',') !== false) {
return false;
}
}
return is_callable($functionName);
}
/**
* Execute. - A substitute for exec()
*
* Same signature and results as exec(): https://www.php.net/manual/en/function.exec.php
* In case neither exec(), nor emulations are available, it throws an Exception.
* This is more gentle than real exec(), which on some systems throws a FATAL when exec() is disabled
* If you want the more acurate substitute, which might halt execution, use execNoMercy() instead.
*
* @param string $command The command to execute
* @param string &$output (optional)
* @param int &$result_code (optional)
*
* @return string | false The last line of output or false in case of failure
* @throws \Exception If no methods are available
*/
public static function exec($command, &$output = null, &$result_code = null)
{
foreach (self::$methods as $method) {
if (self::functionEnabled($method)) {
if (func_num_args() >= 3) {
if ($method == 'shell_exec') {
continue;
}
$result = self::runExec($method, $command, $output, $result_code);
} else {
$result = self::runExec($method, $command, $output);
}
if ($result !== false) {
return $result;
}
}
}
if (isset($result) && ($result === false)) {
return false;
}
throw new \Exception('exec() is not available');
}
/**
* Execute. - A substitute for exec(), with exact same errors thrown if exec() is missing.
*
* Danger: On some systems, this results in a fatal (non-catchable) error.
*/
public static function execNoMercy($command, &$output = null, &$result_code = null)
{
if (func_num_args() == 3) {
return ExecWithFallbackNoMercy::exec($command, $output, $result_code);
} else {
return ExecWithFallbackNoMercy::exec($command, $output);
}
}
public static function runExec($method, $command, &$output = null, &$result_code = null)
{
switch ($method) {
case 'exec':
return exec($command, $output, $result_code);
case 'passthru':
return Passthru::exec($command, $output, $result_code);
case 'popen':
return POpen::exec($command, $output, $result_code);
case 'proc_open':
return ProcOpen::exec($command, $output, $result_code);
case 'shell_exec':
if (func_num_args() == 4) {
return ShellExec::exec($command, $output, $result_code);
} else {
return ShellExec::exec($command, $output);
}
}
return false;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace ExecWithFallback;
/**
* Execute command with exec(), open_proc() or whatever available
*
* @package ExecWithFallback
* @author Bjørn Rosell <it@rosell.dk>
*/
class ExecWithFallbackNoMercy
{
/**
* Execute. - A substitute for exec()
*
* Same signature and results as exec(): https://www.php.net/manual/en/function.exec.php
*
* This is our hardcore version of our exec(). It does not merely throw an Exception, if
* no methods are available. It calls exec().
* This ensures exactly same behavior as normal exec() - the same error is thrown.
* You might want that. But do you really?
* DANGER: On some systems, calling a disabled exec() results in a fatal (non-catchable) error.
*
* @param string $command The command to execute
* @param string &$output (optional)
* @param int &$result_code (optional)
*
* @return string | false The last line of output or false in case of failure
* @throws \Exception|\Error If no methods are available. Note: On some systems, it is FATAL!
*/
public static function exec($command, &$output = null, &$result_code = null)
{
foreach (self::$methods as $method) {
if (self::functionEnabled($method)) {
if (func_num_args() >= 3) {
if ($method == 'shell_exec') {
continue;
}
$result = self::runExec($method, $command, $output, $result_code);
} else {
$result = self::runExec($method, $command, $output);
}
if ($result !== false) {
return $result;
}
}
}
if (isset($result) && ($result === false)) {
return false;
}
// MIGHT THROW FATAL!
return exec($command, $output, $result_code);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace ExecWithFallback;
/**
* Emulate exec() with system()
*
* @package ExecWithFallback
* @author Bjørn Rosell <it@rosell.dk>
*/
class POpen
{
/**
* Emulate exec() with system()
*
* @param string $command The command to execute
* @param string &$output (optional)
* @param int &$result_code (optional)
*
* @return string | false The last line of output or false in case of failure
*/
public static function exec($command, &$output = null, &$result_code = null)
{
$handle = @popen($command, "r");
if ($handle === false) {
return false;
}
$result = '';
while (!@feof($handle)) {
$result .= fread($handle, 1024);
}
//Note: Unix Only:
// pclose() is internally implemented using the waitpid(3) system call.
// To obtain the real exit status code the pcntl_wexitstatus() function should be used.
$result_code = pclose($handle);
$theOutput = preg_split('/\s*\r\n|\s*\n\r|\s*\n|\s*\r/', $result);
// remove the last element if it is blank
if ((count($theOutput) > 0) && ($theOutput[count($theOutput) -1] == '')) {
array_pop($theOutput);
}
if (count($theOutput) == 0) {
return '';
}
if (gettype($output) == 'array') {
foreach ($theOutput as $line) {
$output[] = $line;
}
} else {
$output = $theOutput;
}
return $theOutput[count($theOutput) -1];
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace ExecWithFallback;
/**
* Emulate exec() with passthru()
*
* @package ExecWithFallback
* @author Bjørn Rosell <it@rosell.dk>
*/
class Passthru
{
/**
* Emulate exec() with passthru()
*
* @param string $command The command to execute
* @param string &$output (optional)
* @param int &$result_code (optional)
*
* @return string | false The last line of output or false in case of failure
*/
public static function exec($command, &$output = null, &$result_code = null)
{
ob_start();
// Note: We use try/catch in order to close output buffering in case it throws
try {
passthru($command, $result_code);
} catch (\Exception $e) {
ob_get_clean();
passthru($command, $result_code);
} catch (\Throwable $e) {
ob_get_clean();
passthru($command, $result_code);
}
$result = ob_get_clean();
// split new lines. Also remove trailing space, as exec() does
$theOutput = preg_split('/\s*\r\n|\s*\n\r|\s*\n|\s*\r/', $result);
// remove the last element if it is blank
if ((count($theOutput) > 0) && ($theOutput[count($theOutput) -1] == '')) {
array_pop($theOutput);
}
if (count($theOutput) == 0) {
return '';
}
if (gettype($output) == 'array') {
foreach ($theOutput as $line) {
$output[] = $line;
}
} else {
$output = $theOutput;
}
return $theOutput[count($theOutput) -1];
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace ExecWithFallback;
/**
* Emulate exec() with proc_open()
*
* @package ExecWithFallback
* @author Bjørn Rosell <it@rosell.dk>
*/
class ProcOpen
{
/**
* Emulate exec() with proc_open()
*
* @param string $command The command to execute
* @param string &$output (optional)
* @param int &$result_code (optional)
*
* @return string | false The last line of output or false in case of failure
*/
public static function exec($command, &$output = null, &$result_code = null)
{
$descriptorspec = array(
//0 => array("pipe", "r"),
1 => array("pipe", "w"),
//2 => array("pipe", "w"),
//2 => array("file", "/tmp/error-output.txt", "a")
);
$cwd = getcwd(); // or is "/tmp" better?
$processHandle = proc_open($command, $descriptorspec, $pipes, $cwd);
$result = "";
if (is_resource($processHandle)) {
// Got this solution here:
// https://stackoverflow.com/questions/5673740/php-or-apache-exec-popen-system-and-proc-open-commands-do-not-execute-any-com
//fclose($pipes[0]);
$result = stream_get_contents($pipes[1]);
fclose($pipes[1]);
//fclose($pipes[2]);
$result_code = proc_close($processHandle);
// split new lines. Also remove trailing space, as exec() does
$theOutput = preg_split('/\s*\r\n|\s*\n\r|\s*\n|\s*\r/', $result);
// remove the last element if it is blank
if ((count($theOutput) > 0) && ($theOutput[count($theOutput) -1] == '')) {
array_pop($theOutput);
}
if (count($theOutput) == 0) {
return '';
}
if (gettype($output) == 'array') {
foreach ($theOutput as $line) {
$output[] = $line;
}
} else {
$output = $theOutput;
}
return $theOutput[count($theOutput) -1];
} else {
return false;
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace ExecWithFallback;
/**
* Emulate exec() with system()
*
* @package ExecWithFallback
* @author Bjørn Rosell <it@rosell.dk>
*/
class ShellExec
{
/**
* Emulate exec() with shell_exec()
*
* @param string $command The command to execute
* @param string &$output (optional)
* @param int &$result_code (optional)
*
* @return string | false The last line of output or false in case of failure
*/
public static function exec($command, &$output = null, &$result_code = null)
{
$resultCodeSupplied = (func_num_args() >= 3);
if ($resultCodeSupplied) {
throw new \Exception('ShellExec::exec() does not support $result_code argument');
}
$result = shell_exec($command);
// result:
// - A string containing the output from the executed command,
// - false if the pipe cannot be established
// - or null if an error occurs or the command produces no output.
if ($result === false) {
return false;
}
if (is_null($result)) {
// hm, "null if an error occurs or the command produces no output."
// What were they thinking?
// And yes, it does return null, when no output, which is confirmed in the test "echo hi 1>/dev/null"
// What should we do? Throw or accept?
// Perhaps shell_exec throws in newer versions of PHP instead of returning null.
// We are counting on it until proved wrong.
return '';
}
$theOutput = preg_split('/\s*\r\n|\s*\n\r|\s*\n|\s*\r/', $result);
// remove the last element if it is blank
if ((count($theOutput) > 0) && ($theOutput[count($theOutput) -1] == '')) {
array_pop($theOutput);
}
if (count($theOutput) == 0) {
return '';
}
if (gettype($output) == 'array') {
foreach ($theOutput as $line) {
$output[] = $line;
}
} else {
$output = $theOutput;
}
return $theOutput[count($theOutput) -1];
}
}

View File

@@ -0,0 +1,9 @@
<?php
include 'vendor/autoload.php';
//include 'src/ExecWithFallback.php';
use ExecWithFallback\ExecWithFallback;
use ExecWithFallback\Tests\ExecWithFallbackTest;
ExecWithFallback::exec('echo hello');
ExecWithFallbackTest::testExec();

9
vendor/rosell-dk/file-util/LICENSE vendored Normal file
View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2018 Bjørn Rosell
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

22
vendor/rosell-dk/file-util/README.md vendored Normal file
View File

@@ -0,0 +1,22 @@
# File Util
[![Build Status](https://github.com/rosell-dk/file-util/workflows/build/badge.svg)](https://github.com/rosell-dk/file-util/actions/workflows/php.yml)
[![Software License](https://img.shields.io/badge/license-MIT-418677.svg)](https://github.com/rosell-dk/file-util/blob/master/LICENSE)
[![Coverage](https://img.shields.io/endpoint?url=https://little-b.it/file-util/code-coverage/coverage-badge.json)](http://little-b.it/file-util/code-coverage/coverage/index.html)
[![Latest Stable Version](https://img.shields.io/packagist/v/rosell-dk/file-util.svg)](https://packagist.org/packages/rosell-dk/file-util)
[![Minimum PHP Version](https://img.shields.io/packagist/php-v/rosell-dk/file-util)](https://php.net)
Just a bunch of handy methods for dealing with files and paths:
- *FileExists::fileExists($path)*:\
A well-behaved version of *file_exists* that throws upon failure rather than emitting a warning
- *FileExists::fileExistsTryHarder($path)*:\
Also well-behaved. Tries FileExists::fileExists(). In case of failure, tries exec()-based implementation
- *PathValidator::checkPath($path)*:\
Check if path looks valid and doesn't contain suspecious patterns
- *PathValidator::checkFilePathIsRegularFile($path)*:\
Check if path points to a regular file (and doesnt match suspecious patterns)

View File

@@ -0,0 +1,69 @@
{
"name": "rosell-dk/file-util",
"description": "Functions for dealing with files and paths",
"type": "library",
"license": "MIT",
"keywords": ["files", "path", "util"],
"scripts": {
"ci": [
"@test",
"@phpcs-all",
"@composer validate --no-check-all --strict",
"@phpstan-global"
],
"phpunit": "phpunit --coverage-text",
"test": "phpunit --coverage-text=build/coverage.txt --coverage-clover=build/coverage.clover --coverage-html=build/coverage --whitelist=src tests",
"test-no-cov": "phpunit --no-coverage tests",
"test-41": "phpunit --no-coverage --configuration 'phpunit-41.xml.dist'",
"test-with-coverage": "phpunit --coverage-text --configuration 'phpunit-with-coverage.xml.dist'",
"test-41-with-coverage": "phpunit --coverage-text --configuration 'phpunit-41.xml.dist'",
"cs-fix-all": [
"php-cs-fixer fix src"
],
"cs-fix": "php-cs-fixer fix",
"cs-dry": "php-cs-fixer fix --dry-run --diff",
"phpcs": "phpcs --standard=phpcs-ruleset.xml",
"phpcs-all": "phpcs --standard=phpcs-ruleset.xml src",
"phpcbf": "phpcbf --standard=PSR2",
"phpstan": "vendor/bin/phpstan analyse src --level=4",
"phpstan-global-old": "~/.composer/vendor/bin/phpstan analyse src --level=4",
"phpstan-global": "~/.config/composer/vendor/bin/phpstan analyse src --level=4"
},
"extra": {
"scripts-descriptions": {
"ci": "Run tests before CI",
"phpcs": "Checks coding styles (PSR2) of file/dir, which you must supply. To check all, supply 'src'",
"phpcbf": "Fix coding styles (PSR2) of file/dir, which you must supply. To fix all, supply 'src'",
"cs-fix-all": "Fix the coding style of all the source files, to comply with the PSR-2 coding standard",
"cs-fix": "Fix the coding style of a PHP file or directory, which you must specify.",
"test": "Launches the preconfigured PHPUnit"
}
},
"autoload": {
"psr-4": { "FileUtil\\": "src/" }
},
"autoload-dev": {
"psr-4": { "FileUtil\\Tests\\": "tests/" }
},
"authors": [
{
"name": "Bjørn Rosell",
"homepage": "https://www.bitwise-it.dk/contact",
"role": "Project Author"
}
],
"require": {
"php": ">=5.4",
"rosell-dk/exec-with-fallback": "^1.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.11",
"phpunit/phpunit": "^9.3",
"squizlabs/php_codesniffer": "3.*",
"phpstan/phpstan": "^1.5",
"mikey179/vfsstream": "^1.6"
},
"config": {
"sort-packages": true
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<ruleset name="Custom Standard">
<description>PSR2 without line ending rule - let git manage the EOL cross the platforms</description>
<rule ref="PSR2" />
<rule ref="Generic.Files.LineEndings">
<exclude name="Generic.Files.LineEndings.InvalidEOLChar"/>
</rule>
</ruleset>

View File

@@ -0,0 +1,96 @@
<?php
namespace FileUtil;
use FileUtil\FileExistsUsingExec;
/**
* A fileExist function free of deception
*
* @package FileUtil
* @author Bjørn Rosell <it@rosell.dk>
*/
class FileExists
{
private static $lastWarning;
/**
* A warning handler that registers that a warning has occured and suppresses it.
*
* The function is a callback used with "set_error_handler".
* It is declared public because it needs to be accessible from the point where the warning is triggered.
*
* @param integer $errno
* @param string $errstr
* @param string $errfile
* @param integer $errline
*
* @return void
*/
public static function warningHandler($errno, $errstr, $errfile, $errline)
{
self::$lastWarning = [$errstr, $errno];
// Suppress the warning by returning void
return;
}
/**
* A well behaved replacement for file_exist that throws upon failure rather than emitting a warning.
*
* @throws \Exception Throws an exception in case file_exists emits a warning
* @return boolean True if file exists. False if it doesn't.
*/
public static function fileExists($path)
{
// There is a challenges here:
// We want to suppress warnings, but at the same time we want to know that it happened.
// We achieve this by registering an error handler
set_error_handler(
array('FileUtil\FileExists', "warningHandler"),
E_WARNING | E_USER_WARNING | E_NOTICE | E_USER_NOTICE
);
self::$lastWarning = null;
$found = @file_exists($path);
// restore previous error handler immediately
restore_error_handler();
// If file_exists returns true, we can rely on there being a file there
if ($found) {
return true;
}
// file_exists returned false.
// this result is only trustworthy if no warning was emitted.
if (is_null(self::$lastWarning)) {
return false;
}
list($errstr, $errno) = self::$lastWarning;
throw new \Exception($errstr, $errno);
}
/**
* A fileExist doing the best it can.
*
* @throws \Exception If it cannot be determined if the file exists
* @return boolean|null True if file exists. False if it doesn't.
*/
public static function fileExistsTryHarder($path)
{
try {
$result = self::fileExists($path);
} catch (\Exception $e) {
try {
$result = FileExistsUsingExec::fileExists($path);
} catch (\Exception $e) {
throw new \Exception('Cannot determine if file exists or not');
} catch (\Throwable $e) {
throw new \Exception('Cannot determine if file exists or not');
}
}
return $result;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace FileUtil;
use ExecWithFallback\ExecWithFallback;
/**
* A fileExist implementation using exec()
*
* @package FileUtil
* @author Bjørn Rosell <it@rosell.dk>
*/
class FileExistsUsingExec
{
/**
* A fileExist based on an exec call.
*
* @throws \Exception If exec cannot be called
* @return boolean|null True if file exists. False if it doesn't.
*/
public static function fileExists($path)
{
if (!ExecWithFallback::anyAvailable()) {
throw new \Exception(
'cannot determine if file exists using exec() or similar - the function is unavailable'
);
}
// Lets try to find out by executing "ls path/to/cwebp"
ExecWithFallback::exec('ls ' . $path, $output, $returnCode);
if (($returnCode == 0) && (isset($output[0]))) {
return true;
}
// We assume that "ls" command is general available!
// As that failed, we can conclude the file does not exist.
return false;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace FileUtil;
use FileUtil\FileExists;
/**
*
*
* @package FileUtil
* @author Bjørn Rosell <it@rosell.dk>
*/
class PathValidator
{
/**
* Check if path looks valid and doesn't contain suspecious patterns.
* The path must meet the following criteria:
*
* - It must be a string
* - No NUL character
* - No control characters between 0-20
* - No phar stream wrapper
* - No php stream wrapper
* - No glob stream wrapper
* - Not empty path
*
* @throws \Exception In case the path doesn't meet all criteria
*/
public static function checkPath($path)
{
if (gettype($path) !== 'string') {
throw new \Exception('File path must be string');
}
if (strpos($path, chr(0)) !== false) {
throw new \Exception('NUL character is not allowed in file path!');
}
if (preg_match('#[\x{0}-\x{1f}]#', $path)) {
// prevents line feed, new line, tab, charater return, tab, ets.
throw new \Exception('Control characters #0-#20 not allowed in file path!');
}
// Prevent phar stream wrappers (security threat)
if (preg_match('#^phar://#', $path)) {
throw new \Exception('phar stream wrappers are not allowed in file path');
}
if (preg_match('#^(php|glob)://#', $path)) {
throw new \Exception('php and glob stream wrappers are not allowed in file path');
}
if (empty($path)) {
throw new \Exception('File path is empty!');
}
}
/**
* Check if path points to a regular file (and doesnt match suspecious patterns).
*
* @throws \Exception In case the path doesn't point to a regular file or matches suspecious patterns
*/
public static function checkFilePathIsRegularFile($path)
{
self::checkPath($path);
if (!FileExists::fileExists($path)) {
throw new \Exception('File does not exist');
}
if (@is_dir($path)) {
throw new \Exception('Expected a regular file, not a dir');
}
}
}

View File

@@ -0,0 +1,2 @@
github: rosell-dk
ko_fi: rosell

View File

@@ -0,0 +1,58 @@
language: php
os: linux
matrix:
fast_finish: true
include:
- name: "PHP 7.4, Xenial"
php: 7.4
dist: xenial
env:
- PHPSTAN=1
- UPLOADCOVERAGE=0
- name: "PHP 7.3, Xenial"
php: 7.3
dist: xenial
env:
- PHPSTAN=1
- UPLOADCOVERAGE=1
- name: "PHP 7.2, Xenial"
php: 7.2
dist: xenial
env:
- PHPSTAN=1
- UPLOADCOVERAGE=0
- name: "PHP 7.1, Xenial"
php: 7.1
dist: xenial
env:
- PHPSTAN=1
- UPLOADCOVERAGE=0
- name: "PHP 7.0, Xenial"
php: 7.0
dist: xenial
env:
- PHPSTAN=0
- UPLOADCOVERAGE=0
- name: "PHP 5.6, Trusty"
php: 5.6
dist: trusty
env:
- PHPSTAN=0
- UPLOADCOVERAGE=0
before_script:
- (composer self-update; true)
- composer install
- if [[ $PHPSTAN == 1 ]]; then composer require --dev phpstan/phpstan:"^0.12.37"; fi
script:
- composer test
- if [[ $PHPSTAN == 1 ]]; then vendor/bin/phpstan analyse src --level=4; fi
after_script:
- |
if [[ $UPLOADCOVERAGE == 1 ]]; then
wget https://scrutinizer-ci.com/ocular.phar
php ocular.phar code-coverage:upload --format=php-clover coverage.clover
fi

View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -0,0 +1,622 @@
# htaccess-capability-tester
[![Latest Stable Version](https://img.shields.io/packagist/v/rosell-dk/htaccess-capability-tester.svg?style=flat-square)](https://packagist.org/packages/rosell-dk/htaccess-capability-tester)
[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%205.6-8892BF.svg?style=flat-square)](https://php.net)
[![Build Status](https://img.shields.io/github/workflow/status/rosell-dk/webp-convert/PHP?logo=GitHub&style=flat-square)](https://github.com/rosell-dk/webp-convert/actions/workflows/php.yml)
[![Coverage Status](https://img.shields.io/scrutinizer/coverage/g/rosell-dk/htaccess-capability-tester.svg?style=flat-square)](https://scrutinizer-ci.com/g/rosell-dk/htaccess-capability-tester/code-structure/master/code-coverage/src/)
[![Quality Score](https://img.shields.io/scrutinizer/g/rosell-dk/htaccess-capability-tester.svg?style=flat-square)](https://scrutinizer-ci.com/g/rosell-dk/htaccess-capability-tester/)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://github.com/rosell-dk/htaccess-capability-tester/blob/master/LICENSE)
Detect *.htaccess* capabilities through live tests.
There are cases where the only way to to learn if a given *.htaccess* capability is enabled / supported on a system is by examining it "from the outside" through a HTTP request. This library is build to handle such testing easily.
This is what happens behind the scenes:
1. Some test files for a given test are put on the server (at least an *.htaccess* file)
2. The test is triggered by doing a HTTP request
3. The response is interpreted
## Usage
To use the library, you must provide a path to where the test files are going to be put and the corresponding URL that they can be reached. Besides that, you just need to pick one of the tests that you want to run.
```php
require 'vendor/autoload.php';
use HtaccessCapabilityTester\HtaccessCapabilityTester;
$hct = new HtaccessCapabilityTester($baseDir, $baseUrl);
if ($hct->moduleLoaded('headers')) {
// mod_headers is loaded (tested in a real .htaccess by using the "IfModule" directive)
}
if ($hct->rewriteWorks()) {
// rewriting works
}
if ($hct->htaccessEnabled() === false) {
// Apache has been configured to ignore .htaccess files
}
// A bunch of other tests are available - see API
```
While having a reliable *moduleLoaded()* method is a great improvement over current state of affairs, beware that it is possible that the server has ie *mod_rewrite* enabled, but at the same time has disallowed using ie the "RewriteRule" directive in *.htaccess* files. This is why the library has the *rewriteWorks()* method and similar methods for testing various capabilites fully (check the API overview below). Providing tests for all kinds of functionality, would however be too much for any library. Instead this library makes it a breeze to define a custom test and run it through the *customTest($def)* method. To learn more, check out the [Running your own custom tests](https://github.com/rosell-dk/htaccess-capability-tester/blob/master/docs/Running%20your%20own%20custom%20tests.md) document.
## API overview
### Test methods in HtaccessCapabilityTester
All the test methods returns a test result, which is *true* for success, *false* for failure or *null* for inconclusive.
The tests have the following in common:
- If the server has been set up to ignore *.htaccess* files entirely, the result will be *failure*.
- If the server has been set up to disallow the directive being tested (AllowOverride), the result is *failure* (both when configured to ignore and when configured to go fatal)
- A *403 Forbidden* results in *inconclusive*. Why? Because it could be that the server has been set up to forbid access to files matching a pattern that our test file unluckily matches. In most cases, this is unlikely, as most tests requests files with harmless-looking file extensions (often a "request-me.txt"). A few of the tests however requests a "test.php", which is more likely to be denied.
- A *404 Not Found* results in *inconclusive*
- If the request fails completely (ie timeout), the result is *inconclusive*
Most tests are implemented as a definition such as the one accepted in *customTest()*. This means that if you want one of the tests provided by this library to work slightly differently, you can easily grab the code in the corresponding class in the *Testers* directory, make your modification and call *customTest()*.
<details><summary><b>addTypeWorks()</b></summary>
<p><br>
Tests if the *AddType* directive works.
Implementation (YAML definition):
```yaml
subdir: add-type
files:
- filename: '.htaccess'
content: |
<IfModule mod_mime.c>
AddType image/gif .test
</IfModule>
- filename: 'request-me.test'
content: 'hi'
request:
url: 'request-me.test'
interpretation:
- ['success', 'headers', 'contains-key-value', 'Content-Type', 'image/gif']
- ['inconclusive', 'status-code', 'not-equals', '200']
- ['failure', 'headers', 'not-contains-key-value', 'Content-Type', 'image/gif']
```
</p>
</details>
<details><summary><b>contentDigestWorks()</b></summary>
<p>
Implementation (YAML definition):
```yaml
subdir: content-digest
subtests:
- subdir: on
files:
- filename: '.htaccess'
content: |
ContentDigest On
- filename: 'request-me.txt'
content: 'hi'
request:
url: 'request-me.txt'
interpretation:
- ['failure', 'headers', 'not-contains-key', 'Content-MD5'],
- subdir: off
files:
- filename: '.htaccess'
content: |
ContentDigest Off
- filename: 'request-me.txt'
content: 'hi'
request:
url: 'request-me.txt'
interpretation:
- ['failure', 'headers', 'contains-key', 'Content-MD5']
- ['inconclusive', 'status-code', 'not-equals', '200']
- ['success', 'status-code', 'equals', '200']
```
</p>
</details>
<details><summary><b>crashTest($rules, $subdir)</b></summary>
<p><br>
Test if some rules makes the server "crash" (respond with 500 Internal Server Error for requests to files in the folder).
You pass the rules that you want to check.
You can optionally pass in a subdir for the tests. If you do not do that, a hash of the rules will be used.
Implementation (PHP):
```php
/**
* @param string $htaccessRules The rules to check
* @param string $subSubDir subdir for the test files. If not supplied, a fingerprint of the rules will be used
*/
public function __construct($htaccessRules, $subSubDir = null)
{
if (is_null($subSubDir)) {
$subSubDir = hash('md5', $htaccessRules);
}
$test = [
'subdir' => 'crash-tester/' . $subSubDir,
'subtests' => [
[
'subdir' => 'the-suspect',
'files' => [
['.htaccess', $htaccessRules],
['request-me.txt', 'thanks'],
],
'request' => [
'url' => 'request-me.txt',
'bypass-standard-error-handling' => ['all']
],
'interpretation' => [
['success', 'status-code', 'not-equals', '500'],
]
],
[
'subdir' => 'the-innocent',
'files' => [
['.htaccess', '# I am no trouble'],
['request-me.txt', 'thanks'],
],
'request' => [
'url' => 'request-me.txt',
'bypass-standard-error-handling' => ['all']
],
'interpretation' => [
// The suspect crashed. But if the innocent crashes too, we cannot judge
['inconclusive', 'status-code', 'equals', '500'],
// The innocent did not crash. The suspect is guilty!
['failure'],
]
],
]
];
parent::__construct($test);
}
```
</p>
</details>
<details><summary><b>customTest($definition)</b></summary>
<p>
Allows you to run a custom test. Check out README.md for instructions
</p>
</details>
<details><summary><b>directoryIndexWorks()</b></summary>
<p><br>
Tests if DirectoryIndex works.
Implementation (YAML definition):
```yaml
subdir: directory-index
files:
- filename: '.htaccess'
content: |
<IfModule mod_dir.c>
DirectoryIndex index2.html
</IfModule>
- filename: 'index.html'
content: '0'
- filename: 'index2.html'
content: '1'
request:
url: '' # We request the index, that is why its empty
bypass-standard-error-handling: ['404']
interpretation:
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['failure', 'status-code', 'equals', '404'] # "index.html" might not be set to index
```
</p>
</details>
<details><summary><b>headerSetWorks()</b></summary>
<p><br>
Tests if setting a response header works using the *Header* directive.
Implementation (YAML definition):
```yaml
subdir: header-set
files:
- filename: '.htaccess'
content: |
<IfModule mod_headers.c>
Header set X-Response-Header-Test: test
</IfModule>
- filename: 'request-me.txt'
content: 'hi'
request:
url: 'request-me.txt'
interpretation:
- [success, headers, contains-key-value, 'X-Response-Header-Test', 'test'],
- [failure]
```
</p>
</details>
<details><summary><b>htaccessEnabled()</b></summary>
<p><br>
Apache can be configured to ignore *.htaccess* files altogether. This method tests if the *.htaccess* file is processed at all
The method works by trying out a series of subtests until a conclusion is reached. It will never come out inconclusive.
How does it work?
- The first strategy is testing a series of features, such as `rewriteWorks()`. If any of them works, well, then the *.htaccess* must have been processed.
- Secondly, the `serverSignatureWorks()` is tested. The "ServerSignature" directive is special because it is in core and cannot be disabled with AllowOverride. If this test comes out as a failure, it is so *highly likely* that the .htaccess has not been processed, that we conclude that it has not.
- Lastly, if all other methods failed, we try calling `crashTest()` on an .htaccess file that we on purpose put syntax errors in. If it crashes, the .htaccess file must have been proccessed. If it does not crash, it has not. This last method is bulletproof - so why not do it first? Because it might generate an entry in the error log.
Main part of implementation:
```php
// If we can find anything that works, well the .htaccess must have been proccesed!
if ($hct->serverSignatureWorks() // Override: None, Status: Core, REQUIRES PHP
|| $hct->contentDigestWorks() // Override: Options, Status: Core
|| $hct->addTypeWorks() // Override: FileInfo, Status: Base, Module: mime
|| $hct->directoryIndexWorks() // Override: Indexes, Status: Base, Module: mod_dir
|| $hct->rewriteWorks() // Override: FileInfo, Status: Extension, Module: rewrite
|| $hct->headerSetWorks() // Override: FileInfo, Status: Extension, Module: headers
) {
$status = true;
} else {
// The serverSignatureWorks() test is special because if it comes out as a failure,
// we can be *almost* certain that the .htaccess has been completely disabled
$serverSignatureWorks = $hct->serverSignatureWorks();
if ($serverSignatureWorks === false) {
$status = false;
$info = 'ServerSignature directive does not work - and it is in core';
} else {
// Last bullet in the gun:
// Try an .htaccess with syntax errors in it.
// (we do this lastly because it may generate an entry in the error log)
$crashTestResult = $hct->crashTest('aoeu', 'htaccess-enabled-malformed-htaccess');
if ($crashTestResult === false) {
// It crashed, - which means .htaccess is processed!
$status = true;
$info = 'syntax error in an .htaccess causes crash';
} elseif ($crashTestResult === true) {
// It did not crash. So the .htaccess is not processed, as syntax errors
// makes servers crash
$status = false;
$info = 'syntax error in an .htaccess does not cause crash';
} elseif (is_null($crashTestResult)) {
// It did crash. But so did a request to an innocent text file in a directory
// without a .htaccess file in it. Something is making all requests fail and
// we cannot judge.
$status = null;
$info = 'all requests results in 500 Internal Server Error';
}
}
}
return new TestResult($status, $info);
```
</p>
</details>
<details><summary><b>innocentRequestWorks()</b></summary>
<p><br>
Tests if an innocent request to a text file works. Most tests use this test when they get a 500 Internal Error, in order to decide if this is a general problem (general problem => inconclusive, specific problem => failure).
Implementation (YAML definition):
```yaml
subdir: innocent-request
files:
- filename: 'request-me.txt'
content: 'thank you my dear'
request:
url: 'request-me.txt'
bypass-standard-error-handling: 'all'
interpretation:
- ['success', 'status-code', 'equals', '200']
- ['inconclusive', 'status-code', 'equals', '403']
- ['inconclusive', 'status-code', 'equals', '404']
- ['failure']
```
</p>
</details>
<details><summary><b>moduleLoaded($moduleName)</b></summary>
<p><br>
Tests if a given module is loaded. Note that you in most cases would want to not just know if a module is loaded, but also ensure that the directives you are using are allowed. So for example, instead of calling `moduleLoaded("rewrite")`, you should probably call `rewriteWorks()`;
Implementation:
The method has many ways to test if a module is loaded, based on what works. If for example setting headers has been established to be working and we want to know if "setenvif" module is loaded, the following .htaccess rules will be tested, and the response will be examined.
```
<IfModule mod_setenvif.c>
Header set X-Response-Header-Test: 1
</IfModule>
<IfModule !mod_setenvif.c>
Header set X-Response-Header-Test: 0
</IfModule>
```
</p>
</details>
<details><summary><b>passingInfoFromRewriteToScriptThroughEnvWorks()</b></summary>
<p><br>
Say you have a rewrite rule that points to a PHP script and you would like to pass some information along to the PHP. Usually, you will just pass it in the query string. But this won't do if the information is sensitive. In that case, there are some tricks available. The trick being tested here tells the RewriteRule directive to set an environment variable, which in many setups can be picked up in the script.
Implementation (YAML definition):
```yaml
subdir: pass-info-from-rewrite-to-script-through-env
files:
- filename: '.htaccess'
content: |
<IfModule mod_rewrite.c>
# Testing if we can pass environment variable from .htaccess to script in a RewriteRule
# We pass document root, because that can easily be checked by the script
RewriteEngine On
RewriteRule ^test\.php$ - [E=PASSTHROUGHENV:%{DOCUMENT_ROOT},L]
</IfModule>
- filename: 'test.php'
content: |
<?php
/**
* Get environment variable set with mod_rewrite module
* Return false if the environment variable isn't found
*/
function getEnvPassedInRewriteRule($envName) {
// Environment variables passed through the REWRITE module have "REWRITE_" as a prefix
// (in Apache, not Litespeed, if I recall correctly).
// Multiple iterations causes multiple REWRITE_ prefixes, and we get many environment variables set.
// We simply look for an environment variable that ends with what we are looking for.
// (so make sure to make it unique)
$len = strlen($envName);
foreach ($_SERVER as $key => $item) {
if (substr($key, -$len) == $envName) {
return $item;
}
}
return false;
}
$result = getEnvPassedInRewriteRule('PASSTHROUGHENV');
if ($result === false) {
echo '0';
exit;
}
echo ($result == $_SERVER['DOCUMENT_ROOT'] ? '1' : '0');
request:
url: 'test.php'
interpretation:
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['inconclusive', 'body', 'begins-with', '<?php']
- ['inconclusive']
```
</p>
</details>
<details><summary><b>passingInfoFromRewriteToScriptThroughRequestHeaderWorks()</b></summary>
<p><br>
Say you have a rewrite rule that points to a PHP script and you would like to pass some information along to the PHP. Usually, you will just pass it in the query string. But this won't do if the information is sensitive. In that case, there are some tricks available. The trick being tested here tells the RewriteRule directive to set an environment variable which a RequestHeader directive picks up on and passes on to the script in a request header.
Implementation (YAML definition):
```yaml
subdir: pass-info-from-rewrite-to-script-through-request-header
files:
- filename: '.htaccess'
content: |
<IfModule mod_rewrite.c>
RewriteEngine On
# Testing if we can pass an environment variable through a request header
# We pass document root, because that can easily be checked by the script
<IfModule mod_headers.c>
RequestHeader set PASSTHROUGHHEADER "%{PASSTHROUGHHEADER}e" env=PASSTHROUGHHEADER
</IfModule>
RewriteRule ^test\.php$ - [E=PASSTHROUGHHEADER:%{DOCUMENT_ROOT},L]
</IfModule>
- filename: 'test.php'
content: |
<?php
if (isset($_SERVER['HTTP_PASSTHROUGHHEADER'])) {
echo ($_SERVER['HTTP_PASSTHROUGHHEADER'] == $_SERVER['DOCUMENT_ROOT'] ? 1 : 0);
exit;
}
echo '0';
request:
url: 'test.php'
interpretation:
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['inconclusive', 'body', 'begins-with', '<?php']
- ['inconclusive']
```
</p>
</details>
<details><summary><b>rewriteWorks()</b></summary>
<p><br>
Tests if rewriting works.
Implementation (YAML definition):
```yaml
subdir: rewrite
files:
- filename: '.htaccess'
content: |
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^0\.txt$ 1\.txt [L]
</IfModule>
- filename: '0.txt'
content: '0'
- filename: '1.txt'
content: '1'
request:
url: '0.txt'
interpretation:
- [success, body, equals, '1']
- [failure, body, equals, '0']
```
</p>
</details>
<details><summary><b>requestHeaderWorks()</b></summary>
<p><br>
Tests if a request header can be set using the *RequestHeader* directive.
Implementation (YAML definition):
```yaml
subdir: request-header
files:
- filename: '.htaccess'
content: |
<IfModule mod_headers.c>
# Certain hosts seem to strip non-standard request headers,
# so we use a standard one to avoid a false negative
RequestHeader set User-Agent "request-header-test"
</IfModule>
- filename: 'test.php'
content: |
<?php
if (isset($_SERVER['HTTP_USER_AGENT'])) {
echo $_SERVER['HTTP_USER_AGENT'] == 'request-header-test' ? 1 : 0;
} else {
echo 0;
}
request:
url: 'test.php'
interpretation:
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['inconclusive', 'body', 'begins-with', '<?php']
```
</p>
</details>
<details><summary><b>serverSignatureWorks()</b></summary>
<p><br>
Tests if the *ServerSignature* directive works.
Implementation (YAML definition):
```yaml
subdir: server-signature
subtests:
- subdir: on
files:
- filename: '.htaccess'
content: |
ServerSignature On
- filename: 'test.php'
content: |
<?php
if (isset($_SERVER['SERVER_SIGNATURE']) && ($_SERVER['SERVER_SIGNATURE'] != '')) {
echo 1;
} else {
echo 0;
}
request:
url: 'test.php'
interpretation:
- ['inconclusive', 'body', 'isEmpty']
- ['inconclusive', 'status-code', 'not-equals', '200']
- ['failure', 'body', 'equals', '0']
- subdir: off
files:
- filename: '.htaccess'
content: |
ServerSignature Off
- filename: 'test.php'
content: |
<?php
if (isset($_SERVER['SERVER_SIGNATURE']) && ($_SERVER['SERVER_SIGNATURE'] != '')) {
echo 0;
} else {
echo 1;
}
request:
url: 'test.php'
interpretation:
- ['inconclusive', 'body', 'isEmpty']
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['inconclusive']
```
</p>
</details>
### Other methods in HtaccessCapabilityTester
<details><summary><b>setHttpRequester($requester)</b></summary>
<p><br>
This allows you to use another object for making HTTP requests than the standard one provided by this library. The standard one uses `file_get_contents` to make the request and is implemented in `SimpleHttpRequester.php`. You might for example prefer to use *curl* or, if you are making a Wordpress plugin, you might want to use the one provided by the Wordpress framework.
</p>
</details>
<details><summary><b>setTestFilesLineUpper($testFilesLineUpper)</b></summary>
<p><br>
This allows you to use another object for lining up the test files than the standard one provided by this library. The standard one uses `file_put_contents` to save files and is implemented in `SimpleTestFileLineUpper.php`. You will probably not need to swap the test file line-upper.
</p>
</details>
## Stable API?
The 0.9 release is just about right. I do not expect any changes in the part of the API that is mentioned above. So, if you stick to that, it should still work, when the 1.0 release comes.
Changes in the new 0.9 release:
- Request failures (such as timeout) results in *inconclusive*.
- If you have implemented your own HttpRequester rather than using the default, you need to update it. It must now return status code "0" if the request failed (ie timeout)
Expected changes in the 1.0 release:
- TestResult class might be disposed off so the "internal" Tester classes also returns bool|null.
- Throw custom exception when test file cannot be created
## Installation
Require the library with *Composer*, like this:
```text
composer require rosell-dk/htaccess-capability-tester
```

View File

@@ -0,0 +1,65 @@
{
"name": "rosell-dk/htaccess-capability-tester",
"description": "Test the capabilities of .htaccess files on the server using live tests",
"type": "library",
"license": "MIT",
"keywords": [".htaccess", "apache", "litespeed"],
"scripts": {
"ci": [
"@phpcs src",
"@composer validate --no-check-all --strict",
"@phpstan-global",
"@test-no-cov"
],
"test": "phpunit --coverage-text",
"phpunit": "phpunit --coverage-text",
"test-no-cov": "phpunit --no-coverage",
"cs-fix-all": [
"php-cs-fixer fix src"
],
"cs-fix": "php-cs-fixer fix",
"cs-dry": "php-cs-fixer fix --dry-run --diff",
"phpcs": "phpcs --standard=PSR2",
"phpcbf": "phpcbf --standard=PSR2",
"phpstan": "vendor/bin/phpstan analyse src --level=4",
"phpstan-global": "~/.config/composer/vendor/bin/phpstan analyse src --level=4"
},
"extra": {
"scripts-descriptions": {
"ci": "Run tests before CI",
"phpcs": "Checks coding styles (PSR2) of file/dir, which you must supply. To check all, supply 'src'",
"phpcbf": "Fix coding styles (PSR2) of file/dir, which you must supply. To fix all, supply 'src'",
"cs-fix-all": "Fix the coding style of all the source files, to comply with the PSR-2 coding standard",
"cs-fix": "Fix the coding style of a PHP file or directory, which you must specify.",
"test": "Launches the preconfigured PHPUnit"
}
},
"autoload": {
"psr-4": { "HtaccessCapabilityTester\\": "src/" }
},
"autoload-dev": {
"psr-4": { "HtaccessCapabilityTester\\Tests\\": "tests/" }
},
"authors": [
{
"name": "Bjørn Rosell",
"homepage": "https://www.bitwise-it.dk/contact",
"role": "Project Author"
}
],
"require": {
"php": "^5.6 | ^7.0 | ^8.0"
},
"suggest": {
"php-stan/php-stan": "Suggested for dev, in order to analyse code before committing"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "3.*"
},
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
"sort-packages": true
}
}

View File

@@ -0,0 +1,41 @@
# Grant All Crash Testing
This library used to have a class for crash-testing specific .htaccess rules commonly used in an attempt to grant access to specific files. Such directives are however "dangerous" to use because it is not uncommon that the server has been configured not to allow authorization directives like "Order" and "Require" and even set up to go fatal.
I removed the class, as I found it a bit too specialized.
Here is the `.htaccess` it was testing:
```
# This .htaccess is here in order to test if it results in a 500 Internal Server Error.
# .htaccess files can result in 500 Internal Server Error when they contain directives that has
# not been allowed for the directory it is in (that stuff is controlled with "AllowOverride" or
# "AllowOverrideList" in httpd.conf)
#
# The use case of a .htaccess file like the one tested here would be an attempt to override
# meassurements taken to prevent access. As an example, in Wordpress, there are security plugins
# which puts "Require all denied" into .htaccess files in certain directories in order to strengthen
# security. Such security meassurements could even be applied to the plugins directory, as plugins
# normally should not need PHPs to be requested directly. But of course, there are cases where plugin
# authors need to anyway and thus find themselves counterfighting the security plugin with an .htaccess
# like this. But in doing so, they run the risk of the 500 Internal Server Error. There are standard
# setups out there which not only does not allow "Require" directives, but are configured to go fatal
# about it.
#
# The following directives is used in this .htaccess file:
# - Require (Override: AuthConfig)
# - Order (Override: Limit)
# - FilesMatch (Override: All)
# - IfModule (Override: All)
# FilesMatch should usually be used in this use case, as you would not want to be granting more access
# than you need
<FilesMatch "ping\.txt$">
<IfModule !mod_authz_core.c>
Order deny,allow
Allow from all
</IfModule>
<IfModule mod_authz_core.c>
Require all granted
</IfModule>
</FilesMatch>
```

View File

@@ -0,0 +1,238 @@
Which name is best?
```php
if ($hct->rewriteWorks()) {
}
if ($hct->addTypeWorks()) {
}
if ($hct->serverSignatureWorks()) {
}
if ($hct->contentDigestWorks()) {
}
$hct->rewriteWorks();
$hct->canRewrite();
$hct->rewrite()
$hct->isRewriteWorking();
$hct->canUseRewrite();
$hct->hasRewrite()
$hct->mayRewrite()
$hct->doesRewriteWork();
$hct->rewriting();
$hct->rewritingWorks();
$hct->RewriteRule();
$hct->rewriteSupported();
$hct->rewriteWorks();
$hct->testRewriting();
$hct->test('RewriteRule');
$hct->runTest(new RewriteTester());
$hct->runTest()->RewriteRule();
$hct->canDoRewrite();
$hct->haveRewrite();
$hct->rewriteAvail();
$hct->isRewriteAvailable();
$hct->isRewriteAccessible();
$hct->isRewriteOperative();
$hct->isRewriteOperational();
$hct->isRewriteFunctional();
$hct->isRewritePossible();
$hct->isRewriteOk();
$hct->isRewriteFlying();
$hct->rewritePasses();
if ($hct->canRewrite()) {
}
if ($hct->rewriteWorks()) {
}
if ($hct->rewriteOk()) {
}
if ($hct->rewriteQM()) {
}
if ($hct->rewriteNA()) {
}
// --------------
$hct->canAddType();
$hct->addTypeWorks();
$hct->addTypeQM();
$hct->canUseAddType();
$hct->doesAddTypeWork();
$hct->addType();
$hct->AddType();
$hct->addTypeSupported();
$hct->addTypeWorks();
$hct->addTypeLive();
$hct->addTypeYes();
$hct->addTypeFF(); // fully functional
$hct->testAddType();
$hct->test('AddType');
$hct->run(new AddTypeTester());
$hct->runTest('AddType');
$hct->runTest()->AddType();
$hct->runTest(\HtaccessCapabilityTester\AddType);
$hct->canIUse('AddType');
// ------------------
if ($hct->canContentDigest()) {
}
if ($hct->contentDigestWorks()) {
}
if ($hct->contentDigestOk()) {
}
if ($hct->contentDigestFF()) {
}
$hct->canContentDigest();
$hct->contentDigestWorks();
$hct->canUseContentDigest();
$hct->doesContentDigestWork();
$hct->contentDigest();
$hct->ContentDigest();
// ---------------------
if ($hct->serverSignatureWorks()) {
}
if ($hct->canSetServerSignature()) {
}
if ($hct->testServerSignature()) {
}
if ($hct->doesServerSignatureWork()) {
}
if ($hct->isServerSignatureAllowed()) {
}
if ($hct->isServerSignatureWorking()) {
}
// --------------------
$hct->modRewriteLoaded();
$hct->moduleLoaded('rewrite');
$hct->testModuleLoaded('rewrite');
$hct->modLoaded('rewrite');
// --------------------
$hct->doesThisCrash();
$hct->kaput();
$hct->ooo();
$hct->na();
```
# IDEA:
```yaml
subdir: rewrite
files:
- filename: '.htaccess'
content: |
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^0\.txt$ 1\.txt [L]
</IfModule>
- filename: '0.txt'
content: '0'
- filename: '1.txt'
content: '1'
request:
url: '0.txt'
interpretation:
- [success, body, equals, '1']
- [failure, body, equals, '0']
- [interprete500, status-code, equals, '500'] # inconclusive if innocent also crashes, otherwise failure
- [inconclusive, status-code, equals, '403']
- if: [status-code, equals, '500']
then:
- if: [doesInnocentCrash()]
then: inconclusive
else: failure
- [inconclusive]
```
or:
```yaml
interpretation:
- [success, body, equals, '1']
- [failure, body, equals, '0']
- [handle-errors] # Standard error handling (403, 404, 500)
```
```php
[
'interpretation' => [
[
'if' => ['body', 'equals', '1'],
'then' => ['success']
],
[
'if' => ['body', 'equals', '0'],
'then' => ['failure', 'no-effect']
],
[
'if' => ['status-code', 'equals', '500'],
'then' => 'handle500()'
],
[
'if' => ['status-code', 'equals', '500'],
'then' => 'handle500()'
]
]
```
```yaml
```
crashTestInnocent
handle500:
returns "failure" if innocent request succeeds
returns "inconclusive" if innocent request fails
handle403:
if innocent request also 403, all requests probably does
returns "failure" if innocent request succeeds

View File

@@ -0,0 +1,54 @@
## More examples of what you can test:
```php
require 'vendor/autoload.php';
use HtaccessCapabilityTester\HtaccessCapabilityTester;
$hct = new HtaccessCapabilityTester($baseDir, $baseUrl);
$rulesToCrashTest = <<<'EOD'
<ifModule mod_rewrite.c>
RewriteEngine On
</ifModule>
EOD;
if ($hct->crashTest($rulesToCrashTest)) {
// The rules at least did not cause requests to anything in the folder to "crash".
// (even simple rules like the above can make the server respond with a
// 500 Internal Server Error - see "docs/TheManyWaysOfHtaccessFailure.md")
}
if ($hct->addTypeWorks()) {
// AddType directive works
}
if ($hct->headerSetWorks()) {
// "Header set" works
}
if ($hct->requestHeaderWorks()) {
// "RequestHeader set" works
}
// Note that the tests returns null if they are inconclusive
$testResult = $hct->htaccessEnabled();
if (is_null($testResult)) {
// Inconclusive!
// Perhaps a 403 Forbidden?
// You can get a bit textual insight by using:
// $hct->infoFromLastTest
}
// Also note that an exception will be thrown if test files cannot be created.
// You might want to wrap your call in a try-catch statement.
try {
if ($hct->requestHeaderWorks()) {
// "RequestHeader set" works
}
} catch (\Exception $e) {
// Probably permission problems.
// We should probably notify someone
}
```

View File

@@ -0,0 +1,85 @@
# Running your own custom tests using the *customTest* method
A typical test s mentioned, a test has three phases:
1. Writing the test files to the directory in question
2. Doing a request (in advanced cases, more)
3. Interpreting the request
So, in order for *customTest()*, it needs to know. 1) What files are needed? 2) Which file should be requested? 3) How should the response be interpreted?
Here is a definition which can be used for implementing the *headerSetWorks()* functionality yourself. It's in YAML because it is more readable like this.
<details><summary><u>Click here to see the PHP example</u></summary>
<p><br>
<b>PHP example</b>
```php
<?php
require 'vendor/autoload.php';
use HtaccessCapabilityTester\HtaccessCapabilityTester;
$hct = new HtaccessCapabilityTester($baseDir, $baseUrl);
$htaccessFile = <<<'EOD'
<IfModule mod_headers.c>
Header set X-Response-Header-Test: test
</IfModule>
EOD;
$test = [
'subdir' => 'header-set',
'files' => [
['.htaccess', $htaccessFile],
['request-me.txt', "hi"],
],
'request' => 'request-me.txt',
'interpretation' => [
['success', 'headers', 'contains-key-value', 'X-Response-Header-Test', 'test'],
// the next three mappings are actually not necessary, as customTest() does standard
// error handling automatically (can be turned off)
['failure', 'status-code', 'equals', '500'],
['inconclusive', 'status-code', 'equals', '403'],
['inconclusive', 'status-code', 'equals', '404'],
]
];
if ($hct->customTest($test)) {
// setting a header in the .htaccess works!
}
```
</p>
</details>
```yaml
subdir: header-set
files:
- filename: '.htaccess'
content: |
<IfModule mod_headers.c>
Header set X-Response-Header-Test: test
</IfModule>
- filename: 'request-me.txt'
content: 'hi'
request:
url: 'request-me.txt'
interpretation:
- [success, headers, contains-key-value, 'X-Response-Header-Test', 'test']
- [failure, status-code, equals, '500'] # actually not needed (part of standard error handling)
- [inconclusive, status-code, equals, '403'] # actually not needed (part of standard error handling)
- [inconclusive, status-code, equals, '404'] # actually not needed (part of standard error handling)
- [failure]
```
In fact, this is more or less how this library implements it.
The test definition has the following sub-definitions:
- *subdir*: Defines which subdir the test files should reside in
- *files*: Defines the files for the test (filename and content)
- *request*: Defines which file that should be requested
- *interpretation*: Defines how to interprete the response. It consists of a list of mappings is read from the top until one of the conditions is met. The first line for example translates to "Map to success if the body of the response equals '1'". If none of the conditions are met, the result is automatically mapped to 'inconclusive'.
For more info, look in the API (below). For real examples, check out the classes in the "Testers" dir - most of them are defined in this "language"

View File

@@ -0,0 +1,30 @@
# The many ways of .htaccess failure
If you have written any `.htaccess` files, you are probably comfortable with the "IfModule" tag and the concept that some directives are not available, unless a certain module has been loaded. So, to make your .htaccess failproof, you wrapped those directives in an IfModule tag. Failproof? Wrong!
Meet the [AllowOverride](https://httpd.apache.org/docs/2.4/mod/core.html#allowoverride) and [AllowOverrideList](https://httpd.apache.org/docs/2.4/mod/core.html#allowoverridelist) directives. These fellows effectively controls which directives that are allowed in .htaccess files. It is not a global setting, but something that can be configured per directory. If you are on a specialized host for some CMS, it could very well be that the allowed directives is limited and set to different things in the directories, ie the plugin and media directories.
The settings of AllowOverride and AllowOverrideList can produce three kinds of failures:
1. The .htaccess is skipped altogether. This happens when nothing is allowed (when both AllowOverride and AllowOverrideList are set to None)
2. The forbidden tags are ignored. This happens if the "Nonfatal" setting for AllowOverride is set to "All" or "Override"
3. All requests to the folder/subfolder containing an .htaccess file with forbidden directive results in a 500 Internal Server Error. This happens if the "Nonfatal" option isn't set (or is set to "Unknown"). The IfModule directive does not prevent this from happening.
So, no, using IfModule tags does not make the .htaccess failproof.
Fortunately, the core directives can only be made forbidden in what I take to be a very rare setting: By setting AllowOverride to None and AllowOverrideList to a list, which doesn't include the core directives. So at least, it will be rare to that the IfModule directive itself is forbidden and thereby can cause 500 Internal Server Error.
Besides these cases, there is of course also the authorization directives.
The sysadmin might have placed something like this in the virtual host configuration:
```
<Directory /var/www/your-site/media/ >
<FilesMatch "\.php$">
Require all denied
</FilesMatch>
</Directory>
```
This isn't really a .htaccess failure, but it is an obstacle too. Especially with regards to this library. As we have seen, the capabilities of a .htaccess in one folder is not neccessarily the same in another folder, so we often want to place the .htaccess test files in a subdir to the directory that the real .htaccess files are going to reside. However, if phps aren't allowed to be run there, we can't. Unless of course, the test can be made not to rely on a receiving test.php script. A great amount of effort has been done to avoid resorting to PHP when possible.

View File

@@ -0,0 +1,5 @@
### Running your own test
It is not to define your own test by extending the "AbstractTester" class. You can use the code in one of the provided testers as a template (ie `RequestHeaderTester.php`).
### Using another library for making the HTTP request
This library simply uses `file_get_contents` to make HTTP requests. It can however be set to use another library. Use the `setHttpRequestor` method for that. The requester must implement `HttpRequesterInterface` interface, which simply consists of a single method: `makeHttpRequest($url)`

View File

@@ -0,0 +1,31 @@
"test.php" will either result in "0", "1" or an error.
The tester class then makes a HTTP to `test.php` and examines the response in order to answer the question: *Is "RequestHeader" available and does it work?* It should be clear by inspecting the code above that if `mod_headers` is loaded and `RequestHeader` is allowed in the `.htaccess`, the response of `test.php` will be "1". And if `mod_headers` isn't loaded, the response will be "0". There is however other possibilities. The server can be configured to completely ignore `.htaccess` files (when `AllowOverride` is set to *None* and `AllowOverrideList` is set to *None*). In that case, we will also get a "0", which is appropriate, as this would also mean a "no" to the "available and working?" question. Also, the `RequestHeader` directive might have been disallowed. Exactly which directives that are allowed in an `.htaccess` depends on the configuration of the virtual host and can be set up differently per directory. What happens then, if the directive is forbidden? One of two things. Depending on the "NonFatal" option on the "AllowOverride" directive, Apache will either go fatal on forbidden directives or ignore them. In this case, the ignored directive will result in a "0", which is appropriate. "Going fatal" means responding with a *500 Internal Server Error*. So the tester class must interpret such response as a "no" to the "available and working?" question. Other errors are possible. For example *404 Not Found*. In that case, the problem is probably that the test was set up wrong and throwing an Exception is appropriate. How about *429 Too Many Requests*? It would mean that the test could not be run *at this time* and an inconclusive answer would seem appropriate. However, you could also argue that as the test failed its purpose (being conclusive), an Exception is appropriate. Throwing exceptions allows users to handle the various cases differently, which is nice. So we go with Exceptions. *403 Forbidden*? I'm unsure. Often, the directory will be forbidden for all users, and then a "no" seems appropriate, however, it could be that it is just forbidden for some users, and in that case, an inconclusive answer seems more suitable - which means throwing an Exception. TODO: decide on this. Summing up: "1" => true, "0" => false, Error => false or throw Exception, depending on the error.
Here is another example, on how
For example, the following two files can be used for answering the question: Is mod_env loaded?
**.htaccess**
```
<IfModule mod_setenvif.c>
ServerSignature On
</IfModule>
<IfModule !mod_setenvif.c>
ServerSignature Off
</IfModule>
```
**test.php**
```
if (isset($_SERVER['SERVER_SIGNATURE']) && ($_SERVER['SERVER_SIGNATURE'] != '')) {
echo 1;
} else {
echo 0;
}
```
I'm a bit proud of this one. The two directives used (`ServerSignature` and `IfModule`) are both part of core. So these will be available, unless Apache is configured to ignore `.htaccess` files altogether in the given directory. The rarely used `ServerSignature` directive has an even more rarely known side-effect: It sets a server variable. By making a request to `test.php`, we
directive is part of core, as is And as you see, the template can easily be modified to test for whatever module. can easily be used to test whatever `ServerSignature` is the only directive that is part of core

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.1/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="false"
processIsolation="false"
stopOnFailure="false"
bootstrap="vendor/autoload.php"
>
<testsuites>
<testsuite name="HtaccessCapabilitTester Test Suite">
<directory>./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">src/</directory>
<exclude>
<directory>./vendor</directory>
<directory>./tests</directory>
</exclude>
</whitelist>
</filter>
<logging>
<log type="junit" target="build/report.junit.xml"/>
<log type="coverage-clover" target="coverage.clover"/>
<log type="coverage-text" target="build/coverage.txt"/>
<log type="coverage-html" target="build/coverage"/>
</logging>
</phpunit>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" backupStaticAttributes="false" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="false" processIsolation="false" stopOnFailure="false" bootstrap="vendor/autoload.php">
<coverage>
<include>
<directory suffix=".php">src/</directory>
</include>
<exclude>
<directory>./vendor</directory>
<directory>./tests</directory>
</exclude>
<report>
<clover outputFile="coverage.clover"/>
<html outputDirectory="build/coverage"/>
<text outputFile="build/coverage.txt"/>
</report>
</coverage>
<testsuites>
<testsuite name="HtaccessCapabilitTester Test Suite">
<directory>./tests/</directory>
</testsuite>
</testsuites>
<logging>
<junit outputFile="build/report.junit.xml"/>
</logging>
</phpunit>

View File

@@ -0,0 +1,334 @@
<?php
namespace HtaccessCapabilityTester;
use \HtaccessCapabilityTester\Testers\AbstractTester;
use \HtaccessCapabilityTester\Testers\AddTypeTester;
use \HtaccessCapabilityTester\Testers\ContentDigestTester;
use \HtaccessCapabilityTester\Testers\CrashTester;
use \HtaccessCapabilityTester\Testers\CustomTester;
use \HtaccessCapabilityTester\Testers\DirectoryIndexTester;
use \HtaccessCapabilityTester\Testers\HeaderSetTester;
use \HtaccessCapabilityTester\Testers\HtaccessEnabledTester;
use \HtaccessCapabilityTester\Testers\InnocentRequestTester;
use \HtaccessCapabilityTester\Testers\ModuleLoadedTester;
use \HtaccessCapabilityTester\Testers\PassInfoFromRewriteToScriptThroughRequestHeaderTester;
use \HtaccessCapabilityTester\Testers\PassInfoFromRewriteToScriptThroughEnvTester;
use \HtaccessCapabilityTester\Testers\RewriteTester;
use \HtaccessCapabilityTester\Testers\RequestHeaderTester;
use \HtaccessCapabilityTester\Testers\ServerSignatureTester;
/**
* Main entrance.
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class HtaccessCapabilityTester
{
/** @var string The dir where the test files should be put */
protected $baseDir;
/** @var string The base url that the tests can be run from (corresponds to $baseDir) */
protected $baseUrl;
/** @var string Additional info regarding last test (often empty) */
public $infoFromLastTest;
/** @var string Status code from last test (can be empty) */
public $statusCodeOfLastRequest;
/** @var HttpRequesterInterface The object used to make the HTTP request */
private $requester;
/** @var TestFilesLineUpperInterface The object used to line up the test files */
private $testFilesLineUpper;
/**
* Constructor.
*
* @param string $baseDir Directory on the server where the test files can be put
* @param string $baseUrl The base URL of the test files
*
* @return void
*/
public function __construct($baseDir, $baseUrl)
{
$this->baseDir = $baseDir;
$this->baseUrl = $baseUrl;
}
/**
* Run a test, store the info and return the status.
*
* @param AbstractTester $tester
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
private function runTest($tester)
{
//$tester->setHtaccessCapabilityTester($this);
if (isset($this->requester)) {
$tester->setHttpRequester($this->requester);
}
if (isset($this->testFilesLineUpper)) {
$tester->setTestFilesLineUpper($this->testFilesLineUpper);
}
//$tester->setHtaccessCapabilityTester($this);
$cacheKeys = [$this->baseDir, $tester->getCacheKey()];
if (TestResultCache::isCached($cacheKeys)) {
$testResult = TestResultCache::getCached($cacheKeys);
} else {
$testResult = $tester->run($this->baseDir, $this->baseUrl);
TestResultCache::cache($cacheKeys, $testResult);
}
$this->infoFromLastTest = $testResult->info;
$this->statusCodeOfLastRequest = $testResult->statusCodeOfLastRequest;
return $testResult->status;
}
/**
* Run a test, store the info and return the status.
*
* @param HttpRequesterInterface $requester
*
* @return void
*/
public function setHttpRequester($requester)
{
$this->requester = $requester;
}
/**
* Set object responsible for lining up the test files.
*
* @param TestFilesLineUpperInterface $testFilesLineUpper
* @return void
*/
public function setTestFilesLineUpper($testFilesLineUpper)
{
$this->testFilesLineUpper = $testFilesLineUpper;
}
/**
* Test if .htaccess files are enabled
*
* Apache can be configured to completely ignore .htaccess files. This test examines
* if .htaccess files are proccesed.
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function htaccessEnabled()
{
return $this->runTest(new HtaccessEnabledTester());
}
/**
* Test if a module is loaded.
*
* This test detects if directives inside a "IfModule" is run for a given module
*
* @param string $moduleName A valid Apache module name (ie "rewrite")
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function moduleLoaded($moduleName)
{
return $this->runTest(new ModuleLoadedTester($moduleName));
}
/**
* Test if rewriting works.
*
* The .htaccess in this test uses the following directives:
* - IfModule
* - RewriteEngine
* - Rewrite
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function rewriteWorks()
{
return $this->runTest(new RewriteTester());
}
/**
* Test if AddType works.
*
* The .htaccess in this test uses the following directives:
* - IfModule (core)
* - AddType (mod_mime, FileInfo)
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function addTypeWorks()
{
return $this->runTest(new AddTypeTester());
}
/**
* Test if setting a Response Header with the Header directive works.
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function headerSetWorks()
{
return $this->runTest(new HeaderSetTester());
}
/**
* Test if setting a Request Header with the RequestHeader directive works.
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function requestHeaderWorks()
{
return $this->runTest(new RequestHeaderTester());
}
/**
* Test if ContentDigest directive works.
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function contentDigestWorks()
{
return $this->runTest(new ContentDigestTester());
}
/**
* Test if ServerSignature directive works.
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function serverSignatureWorks()
{
return $this->runTest(new ServerSignatureTester());
}
/**
* Test if DirectoryIndex works.
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function directoryIndexWorks()
{
return $this->runTest(new DirectoryIndexTester());
}
/**
* Test a complex construct for passing information from a rewrite to a script through a request header.
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function passingInfoFromRewriteToScriptThroughRequestHeaderWorks()
{
return $this->runTest(new PassInfoFromRewriteToScriptThroughRequestHeaderTester());
}
/**
* Test if an environment variable can be set in a rewrite rule and received in PHP.
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function passingInfoFromRewriteToScriptThroughEnvWorks()
{
return $this->runTest(new PassInfoFromRewriteToScriptThroughEnvTester());
}
/**
* Call one of the methods of this class (not all allowed).
*
* @param string $functionCall ie "rewriteWorks()"
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
/*
public function callMethod($functionCall)
{
switch ($functionCall) {
case 'htaccessEnabled()':
return $this->htaccessEnabled();
case 'rewriteWorks()':
return $this->rewriteWorks();
case 'addTypeWorks()':
return $this->addTypeWorks();
case 'headerSetWorks()':
return $this->headerSetWorks();
case 'requestHeaderWorks()':
return $this->requestHeaderWorks();
case 'contentDigestWorks()':
return $this->contentDigestWorks();
case 'directoryIndexWorks()':
return $this->directoryIndexWorks();
case 'passingInfoFromRewriteToScriptThroughRequestHeaderWorks()':
return $this->passingInfoFromRewriteToScriptThroughRequestHeaderWorks();
case 'passingInfoFromRewriteToScriptThroughEnvWorks()':
return $this->passingInfoFromRewriteToScriptThroughEnvWorks();
default:
throw new \Exception('The method is not callable');
}
// TODO: moduleLoaded($moduleName)
}*/
/**
* Crash-test some .htaccess rules.
*
* Tests if the server can withstand the given rules without going fatal.
*
* - success: if the rules does not result in status 500.
* - failure: if the rules results in status 500 while a request to a file in a directory
* without any .htaccess succeeds (<> 500)
* - inconclusive: if the rules results in status 500 while a request to a file in a directory
* without any .htaccess also fails (500)
*
* @param string $rules Rules to crash-test
* @param string $subDir (optional) Subdir for the .htaccess to reside.
* if left out, a unique string will be generated
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function crashTest($rules, $subDir = null)
{
return $this->runTest(new CrashTester($rules, $subDir));
}
/**
* Test an innocent request to a text file.
*
* If this fails, everything else will also fail.
*
* Possible reasons for failure:
* - A .htaccess in a parent folder has forbidden tags / syntax errors
*
* Possible reasons for inconclusive (= test could not be run)
* - 403 Forbidden
* - 404 Not Found
* - Request fails (ie due to timeout)
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function innocentRequestWorks()
{
return $this->runTest(new InnocentRequestTester());
}
/**
* Run a custom test.
*
* @param array $definition
*
* @return bool|null true=success, false=failure, null=inconclusive
*/
public function customTest($definition)
{
return $this->runTest(new CustomTester($definition));
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace HtaccessCapabilityTester;
interface HttpRequesterInterface
{
/**
* Make a HTTP request to a URL.
*
* @return HttpResponse A HttpResponse object, which simply contains body, status code and response headers.
* In case the request itself fails, the status code is "0" and the body should contain
* error description (if available)
*/
public function makeHttpRequest($url);
}

View File

@@ -0,0 +1,75 @@
<?php
namespace HtaccessCapabilityTester;
/**
* Class for holding properties of a HttpResponse
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class HttpResponse
{
/* @var string the body of the response */
public $body;
/* @var string the status code of the response */
public $statusCode;
/* @var array the response headers keyed by lowercased field name */
public $headersMapLowerCase;
/**
* Constructor.
*
* @param string $body
* @param string $statusCode
* @param array $headersMap Map of headers, keyed by field name.
* There is only one value (string) for each key.
* If there are multiple values, they must be separated by comma
*
* @return void
*/
public function __construct($body, $statusCode, $headersMap)
{
$this->body = $body;
$this->statusCode = $statusCode;
$this->headersMapLowerCase = array_change_key_case($headersMap, CASE_LOWER);
}
/**
* Check if the response has a header
*
* @param string $fieldName
* @return bool
*/
public function hasHeader($fieldName)
{
$fieldName = strtolower($fieldName);
return (isset($this->headersMapLowerCase[$fieldName]));
}
/**
* Check if the response has a header with a given value
*
* @param string $fieldName
* @param string $fieldValue
* @return bool
*/
public function hasHeaderValue($fieldName, $fieldValue)
{
$fieldName = strtolower($fieldName);
if (!isset($this->headersMapLowerCase[$fieldName])) {
return false;
}
$values = explode(',', $this->headersMapLowerCase[$fieldName]);
foreach ($values as $value) {
if (trim($value) == $fieldValue) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace HtaccessCapabilityTester;
class SimpleHttpRequester implements HttpRequesterInterface
{
/**
* Make a HTTP request to a URL.
*
* @param string $url The URL to make the HTTP request to
*
* @return HttpResponse A HttpResponse object, which simply contains body, status code and response headers.
* In case the request itself fails, the status code is "0" and the body should contain
* error description (if available)
*/
public function makeHttpRequest($url)
{
// PS: We suppress the E_WARNING level error generated on failure
$body = @file_get_contents($url);
if ($body === false) {
//$body = '';
return new HttpResponse('The following request failed: file_get_contents(' . $url . ')', '0', []);
}
// $http_response_header materializes out of thin air when file_get_contents() is called
// Get status code
$statusLine = $http_response_header[0];
preg_match('{HTTP\/\S*\s(\d{3})}', $statusLine, $match);
$statusCode = $match[1];
// Create headers map
$headersMap = [];
foreach ($http_response_header as $header) {
$pos = strpos($header, ':');
if ($pos > 0) {
$fieldName = strtolower(trim(substr($header, 0, $pos)));
$value = trim(substr($header, $pos + 1));
if (!isset($headersMap[$fieldName])) {
$headersMap[$fieldName] = $value;
} else {
$headersMap[$fieldName] .= ', ' . $value;
}
}
}
return new HttpResponse($body, $statusCode, $headersMap);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace HtaccessCapabilityTester;
class SimpleTestFileLineUpper implements TestFilesLineUpperInterface
{
private function writeFileIfMissingOrChanged($file)
{
$success = true;
list($filename, $content) = $file;
$dir = dirname($filename);
if (!is_dir($dir)) {
if (!mkdir($dir, 0777, true)) {
// TODO: Use custom exception
throw new \Exception('Failed creating dir: ' . $dir);
}
}
if (file_exists($filename)) {
// file already exists, now check if content is the same
$existingContent = file_get_contents($filename);
if (($existingContent === false) || ($content != $existingContent)) {
$success = file_put_contents($filename, $content);
}
} else {
$success = file_put_contents($filename, $content);
}
if (!$success) {
// TODO: Use custom exception
throw new \Exception('Failed creating file: ' . $filename);
}
}
/**
* Write missing and changed files.
*
* @param array $files The files that needs to be there
*
* @return void
*/
private function writeMissingAndChangedFiles($files)
{
foreach ($files as $file) {
$this->writeFileIfMissingOrChanged($file);
}
}
/**
* Remove unused files.
*
* @param array $files The files that needs to be there (others will be removed)
*
* @return void
*/
private function removeUnusedFiles($files)
{
$dirs = [];
foreach ($files as $file) {
list($filename, $content) = $file;
$dir = dirname($filename);
if (!isset($dirs[$dir])) {
$dirs[$dir] = [];
}
$dirs[$dir][] = basename($filename);
}
foreach ($dirs as $dir => $filesSupposedToBeInDir) {
$fileIterator = new \FilesystemIterator($dir);
while ($fileIterator->valid()) {
$filename = $fileIterator->getFilename();
if (!in_array($filename, $filesSupposedToBeInDir)) {
unlink($dir . '/' . $filename);
}
$fileIterator->next();
}
}
}
/**
* Line-up test files.
*
* This method should make sure that the files passed in are there and are up-to-date.
* - If a file is missing, it should be created.
* - If a file has changed content, it should be updated
* - If the directory contains a file/dir that should not be there, it should be removed
*
* @param array $files The files that needs to be there
*
* @return void
*/
public function lineUp($files)
{
// 1. Put missing files / changed files
$this->writeMissingAndChangedFiles($files);
// 2. Remove unused files
$this->removeUnusedFiles($files);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace HtaccessCapabilityTester;
interface TestFilesLineUpperInterface
{
/**
* Line-up test files.
*
* This method should make sure that the files passed in are there and are up-to-date.
* - If a file is missing, it should be created.
* - If a file has changed content, it should be updated
* - If the directory contains a file/dir that should not be there, it should be removed
*
* @param array $files The files that needs to be there
*
* @return void
*/
public function lineUp($files);
}

View File

@@ -0,0 +1,39 @@
<?php
namespace HtaccessCapabilityTester;
/**
* Class for holding properties of a TestResult
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since the beginning
*/
class TestResult
{
/* @var bool|null The result, null if inconclusive */
public $status;
/* @var string Information about how the test failed / became inconclusive */
public $info;
/* @var string Status code of last request in the test */
public $statusCodeOfLastRequest;
/**
* Constructor.
*
* @param bool|null $status
* @param string $info
* @param string $statusCodeOfLastRequest (optional)
*
* @return void
*/
public function __construct($status, $info, $statusCodeOfLastRequest = null)
{
$this->status = $status;
$this->info = $info;
$this->statusCodeOfLastRequest = $statusCodeOfLastRequest;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace HtaccessCapabilityTester;
use \HtaccessCapabilityTester\Testers\AbstractTester;
/**
* Class caching test results
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since the beginning
*/
class TestResultCache
{
/* @var array Array for caching */
protected static $cache;
/**
*
* @param array $cacheKeys Two keys for caching (usually: basedir and the getCacheKey() for the Tester)
* @param TestResult $testResult The test result to cache
*
* @return void
*/
public static function cache($cacheKeys, $testResult)
{
if (!isset(self::$cache)) {
self::$cache = [];
}
list($key1, $key2) = $cacheKeys;
if (!isset(self::$cache[$key1])) {
self::$cache[$key1] = [];
}
self::$cache[$key1][$key2] = $testResult;
}
/**
* Check if in cache.
*
* @param array $cacheKeys Keys for caching (usually: basedir and the getCacheKey() for the Tester)
*
* @return bool
*/
public static function isCached($cacheKeys)
{
if (!isset(self::$cache)) {
return false;
}
list($key1, $key2) = $cacheKeys;
if (!isset(self::$cache[$key1])) {
return false;
}
if (!isset(self::$cache[$key1][$key2])) {
return false;
}
return true;
}
/**
* Get from cache.
*
* @param array $cacheKeys Keys for caching (usually: basedir and the getCacheKey() for the Tester)
*
* @return TestResult The test result
*/
public static function getCached($cacheKeys)
{
if (!self::isCached($cacheKeys)) {
throw new \Exception('Not in cache');
}
list($key1, $key2) = $cacheKeys;
return self::$cache[$key1][$key2];
}
public static function clear()
{
self::$cache = null;
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace HtaccessCapabilityTester\Testers;
use \HtaccessCapabilityTester\HtaccessCapabilityTester;
use \HtaccessCapabilityTester\HttpRequesterInterface;
use \HtaccessCapabilityTester\HttpResponse;
use \HtaccessCapabilityTester\SimpleHttpRequester;
use \HtaccessCapabilityTester\SimpleTestFileLineUpper;
use \HtaccessCapabilityTester\TestFilesLineUpperInterface;
use \HtaccessCapabilityTester\TestResult;
abstract class AbstractTester
{
/** @var string The dir where the test files should be put */
protected $baseDir;
/** @var string The base url that the tests can be run from (corresponds to $baseDir) */
protected $baseUrl;
/** @var string Subdir to put .htaccess files in */
protected $subDir;
/** @var array Test files for the test */
protected $testFiles;
/** @var HttpRequesterInterface An object for making the HTTP request */
protected $httpRequester;
/** @var HttpResponse The response of the previous HTTP request (if any) */
public $lastHttpResponse;
/** @var TestFilesLineUpperInterface An object for lining up the test-files */
protected $testFilesLineUpper;
/** @var HtaccessCapabilityTester The HtaccessCapabilityTester to use for subtests */
private $hct;
/**
* Register the test files using the "registerTestFile" method
*
* @return void
*/
abstract protected function registerTestFiles();
/**
* Child classes must implement this method, which tells which subdir the
* test files are to be put.
*
* @return string A subdir for the test files
*/
abstract protected function getSubDir();
/**
* Get key for caching purposes.
*
* Return a unique key. The default is to use the subdir. However, if a concrete Tester class
* can test different things, it must override this method and make sure to return a different
* key per thing it can test
*
* @return string A key it can be cached under
*/
public function getCacheKey()
{
return $this->getSubDir();
}
public function getBaseDir()
{
return $this->baseDir;
}
public function getBaseUrl()
{
return $this->baseUrl;
}
/**
* Child classes must that implement the registerTestFiles method must call
* this method to register each test file.
*
* @return void
*/
protected function registerTestFile($filename, $content)
{
$this->testFiles[] = [$this->baseDir . '/' . $filename, $content];
}
/**
* Last moment preparations before running the test
*
* @param string $baseDir Directory on the server where the test files can be put
* @param string $baseUrl The base URL of the test files
*
* @throws \Exception In case the test cannot be prepared due to serious issues
*/
protected function prepareForRun($baseDir, $baseUrl)
{
$this->baseDir = $baseDir;
$this->baseUrl = $baseUrl;
$this->testFiles = [];
$this->registerTestFiles();
$this->lineUpTestFiles();
}
abstract public function run($baseDir, $baseUrl);
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$this->subDir = $this->getSubDir();
}
/**
* Make a HTTP request to a URL.
*
* @param string $url The URL to make the HTTP request to
*
* @return HttpResponse A HttpResponse object, which simply contains body and status code.
*/
protected function makeHttpRequest($url)
{
if (!isset($this->httpRequester)) {
$this->httpRequester = new SimpleHttpRequester();
}
$this->lastHttpResponse = $this->httpRequester->makeHttpRequest($url);
return $this->lastHttpResponse;
}
/**
* Set HTTP requester object, which handles making HTTP requests.
*
* @param HttpRequesterInterface $httpRequester The HTTPRequester to use
* @return void
*/
public function setHttpRequester($httpRequester)
{
$this->httpRequester = $httpRequester;
if (isset($this->hct)) {
$this->hct->setHttpRequester($this->httpRequester);
}
}
public function lineUpTestFiles()
{
if (!isset($this->testFilesLineUpper)) {
$this->testFilesLineUpper = new SimpleTestFileLineUpper();
}
$this->testFilesLineUpper->lineUp($this->testFiles);
}
/**
* Set object responsible for lining up the test files.
*
* @param TestFilesLineUpperInterface $testFilesLineUpper
* @return void
*/
public function setTestFilesLineUpper($testFilesLineUpper)
{
$this->testFilesLineUpper = $testFilesLineUpper;
if (isset($this->hct)) {
$this->hct->setTestFilesLineUpper($this->testFilesLineUpper);
}
}
/**
* Get HtaccessCapabilityTester.
*
* Some tests use HtaccessCapabilityTester to run other tests.
* This gets such object with baseDir and baseUrl set up
*
* @return HtaccessCapabilityTester
*/
public function getHtaccessCapabilityTester()
{
if (!isset($this->hct)) {
$this->hct = new HtaccessCapabilityTester($this->baseDir, $this->baseUrl);
if (isset($this->testFilesLineUpper)) {
$this->hct->setTestFilesLineUpper($this->testFilesLineUpper);
}
if (isset($this->httpRequester)) {
$this->hct->setHttpRequester($this->httpRequester);
}
}
return $this->hct;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace HtaccessCapabilityTester\Testers;
/**
* Class for testing if AddType works
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class AddTypeTester extends CustomTester
{
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$htaccessFile = <<<'EOD'
<IfModule mod_mime.c>
AddType image/gif .test
</IfModule>
EOD;
$test = [
'subdir' => 'add-type',
'files' => [
['.htaccess', $htaccessFile],
['request-me.test', 'hi'],
],
'request' => 'request-me.test',
'interpretation' => [
['success', 'headers', 'contains-key-value', 'Content-Type', 'image/gif'],
['inconclusive', 'status-code', 'not-equals', '200'],
['failure', 'headers', 'not-contains-key-value', 'Content-Type', 'image/gif'],
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace HtaccessCapabilityTester\Testers;
/**
* Class for testing if setting ContentDigest works
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class ContentDigestTester extends CustomTester
{
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$test = [
'subdir' => 'content-digest',
'subtests' => [
[
'subdir' => 'on',
'files' => [
['.htaccess', 'ContentDigest On'],
['request-me.txt', 'hi'],
],
'request' => 'request-me.txt',
'interpretation' => [
['failure', 'headers', 'not-contains-key', 'Content-MD5'],
]
],
[
'subdir' => 'off',
'files' => [
['.htaccess', 'ContentDigest Off'],
['request-me.txt', "hi"],
],
'request' => 'request-me.txt',
'interpretation' => [
['failure', 'headers', 'contains-key', 'Content-MD5'],
['inconclusive', 'status-code', 'not-equals', '200'],
['success', 'status-code', 'equals', '200'],
]
]
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace HtaccessCapabilityTester\Testers;
use \HtaccessCapabilityTester\TestResult;
/**
* Class for testing if a .htaccess results in a 500 Internal Server Error
* (ie due to being malformed or containing directives that are unknown or not allowed)
*
* Notes:
* - The tester only reports failure on a 500 Internal Server Error. All other status codes (even server errors)
* are treated as a success. The assumption here is that malformed .htaccess files / .htaccess
* files containing unknown or disallowed directives always results in a 500
* - If your purpose is to test if a request succeeds (response 200 Ok), you should create your own class.
* (note that if you want to ensure that a php will succeed, make sure that a php is requested)
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class CrashTester extends CustomTester
{
/**
* @param string $htaccessRules The rules to check
* @param string $subSubDir subdir for the test files. If not supplied, a fingerprint of the rules will be used
*/
public function __construct($htaccessRules, $subSubDir = null)
{
if (is_null($subSubDir)) {
$subSubDir = hash('md5', $htaccessRules);
}
$test = [
'subdir' => 'crash-tester/' . $subSubDir,
'subtests' => [
[
'subdir' => 'the-suspect',
'files' => [
['.htaccess', $htaccessRules],
['request-me.txt', 'thanks'],
],
'request' => [
'url' => 'request-me.txt',
'bypass-standard-error-handling' => ['403', '404', '500']
],
'interpretation' => [
['success', 'status-code', 'not-equals', '500'],
// Otherwise fall through to next subtest
]
],
[
'subdir' => 'the-innocent',
'files' => [
['.htaccess', '# I am no trouble'],
['request-me.txt', 'thanks'],
],
'request' => [
'url' => 'request-me.txt',
'bypass-standard-error-handling' => ['403', '404', '500']
],
'interpretation' => [
// The suspect crashed. But if the innocent crashes too, we cannot judge
['inconclusive', 'status-code', 'equals', '500'],
// The innocent did not crash. The suspect is guilty!
['failure'],
]
],
]
];
parent::__construct($test);
}
/**
* Child classes must implement this method, which tells which subdir the
* test files are to be put.
*
* @return string A subdir for the test files
*/
public function getSubDir()
{
return 'crash-tester';
}
}

View File

@@ -0,0 +1,230 @@
<?php
namespace HtaccessCapabilityTester\Testers;
use \HtaccessCapabilityTester\HtaccessCapabilityTester;
use \HtaccessCapabilityTester\HttpRequesterInterface;
use \HtaccessCapabilityTester\HttpResponse;
use \HtaccessCapabilityTester\SimpleHttpRequester;
use \HtaccessCapabilityTester\TestResult;
use \HtaccessCapabilityTester\Testers\Helpers\ResponseInterpreter;
class CustomTester extends AbstractTester
{
/** @var array A definition defining the test */
protected $test;
/** @var array For convenience, all tests */
private $tests;
/**
* Constructor.
*
* @param array $test The test (may contain subtests)
*
* @return void
*/
public function __construct($test)
{
$this->test = $test;
if (isset($test['subtests'])) {
$this->tests = $test['subtests'];
// Add main subdir to subdir for all subtests
foreach ($this->tests as &$subtest) {
if (isset($subtest['subdir'])) {
$subtest['subdir'] = $test['subdir'] . '/' . $subtest['subdir'];
}
}
} else {
$this->tests = [$test];
}
//echo '<pre>' . print_r($this->tests, true) . '</pre>';
//echo json_encode($this->tests) . '<br>';
parent::__construct();
}
/**
* Register the test files using the "registerTestFile" method
*
* @return void
*/
protected function registerTestFiles()
{
foreach ($this->tests as $test) {
if (isset($test['files'])) {
foreach ($test['files'] as $file) {
// Two syntaxes are allowed:
// - Simple array (ie: ['0.txt', '0']
// - Named, ie: ['filename' => '0.txt', 'content' => '0']
// The second makes more readable YAML definitions
if (isset($file['filename'])) {
$filename = $file['filename'];
$content = $file['content'];
} else {
list ($filename, $content) = $file;
}
$this->registerTestFile($test['subdir'] . '/' . $filename, $content);
}
}
}
}
public function getSubDir()
{
return $this->test['subdir'];
}
/**
* Standard Error handling
*
* @param HttpResponse $response
*
* @return TestResult|null If no errors, null is returned, otherwise a TestResult
*/
private function standardErrorHandling($response)
{
switch ($response->statusCode) {
case '0':
return new TestResult(null, $response->body);
case '403':
return new TestResult(null, '403 Forbidden');
case '404':
return new TestResult(null, '404 Not Found');
case '500':
$hct = $this->getHtaccessCapabilityTester();
// Run innocent request / get it from cache. This sets
// $statusCodeOfLastRequest, which we need now
$hct->innocentRequestWorks();
if ($hct->statusCodeOfLastRequest == '500') {
return new TestResult(null, 'Errored with 500. Everything errors with 500.');
} else {
return new TestResult(
false,
'Errored with 500. ' .
'Not all goes 500, so it must be a forbidden directive in the .htaccess'
);
}
}
return null;
}
/**
* Checks if standard error handling should be bypassed on the test.
*
* This stuff is controlled in the test definition. More precisely, by the "bypass-standard-error-handling"
* property bellow the "request" property. If this property is set to ie ['404', '500'], the standard error
* handler will be bypassed for those codes (but still be in effect for ie '403'). If set to ['all'], all
* standard error handling will be bypassed.
*
* @param array $test the subtest
* @param HttpResponse $response the response
*
* @return bool true if error handling should be bypassed
*/
private function bypassStandardErrorHandling($test, $response)
{
if (!(isset($test['request']['bypass-standard-error-handling']))) {
return false;
}
$bypassErrors = $test['request']['bypass-standard-error-handling'];
if (in_array($response->statusCode, $bypassErrors) || in_array('all', $bypassErrors)) {
return true;
}
return false;
}
/**
* Run single test
*
* @param array $test the subtest to run
*
* @return TestResult Returns a test result
*/
private function realRunSubTest($test)
{
$requestUrl = $this->baseUrl . '/' . $test['subdir'] . '/';
if (isset($test['request']['url'])) {
$requestUrl .= $test['request']['url'];
} else {
$requestUrl .= $test['request'];
}
//echo $requestUrl . '<br>';
$response = $this->makeHttpRequest($requestUrl);
// Standard error handling
if (!($this->bypassStandardErrorHandling($test, $response))) {
$errorResult = $this->standardErrorHandling($response);
if (!is_null($errorResult)) {
return $errorResult;
}
}
return ResponseInterpreter::interpret($response, $test['interpretation']);
}
/**
* Run
*
* @param string $baseDir Directory on the server where the test files can be put
* @param string $baseUrl The base URL of the test files
*
* @return TestResult Returns a test result
* @throws \Exception In case the test cannot be run due to serious issues
*/
private function realRun($baseDir, $baseUrl)
{
$this->prepareForRun($baseDir, $baseUrl);
$result = null;
foreach ($this->tests as $i => $test) {
/*
Disabled, as I'm no longer sure if it is that useful
if (isset($test['requirements'])) {
$hct = $this->getHtaccessCapabilityTester();
foreach ($test['requirements'] as $requirement) {
$requirementResult = $hct->callMethod($requirement);
if (!$requirementResult) {
// Skip test
continue 2;
}
}
}*/
if (isset($test['request'])) {
$result = $this->realRunSubTest($test);
if ($result->info != 'no-match') {
return $result;
}
}
}
if (is_null($result)) {
$result = new TestResult(null, 'Nothing to test!');
}
return $result;
}
/**
* Run
*
* @param string $baseDir Directory on the server where the test files can be put
* @param string $baseUrl The base URL of the test files
*
* @return TestResult Returns a test result
* @throws \Exception In case the test cannot be run due to serious issues
*/
public function run($baseDir, $baseUrl)
{
$testResult = $this->realRun($baseDir, $baseUrl);
// A test might not create a request if it has an unfulfilled requirement
if (isset($this->lastHttpResponse)) {
$testResult->statusCodeOfLastRequest = $this->lastHttpResponse->statusCode;
}
return $testResult;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace HtaccessCapabilityTester\Testers;
/**
* Class for testing if DirectoryIndex works
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class DirectoryIndexTester extends CustomTester
{
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$htaccessFile = <<<'EOD'
<IfModule mod_dir.c>
DirectoryIndex index2.html
</IfModule>
EOD;
$test = [
'subdir' => 'directory-index',
'files' => [
['.htaccess', $htaccessFile],
['index.html', "0"],
['index2.html', "1"]
],
'request' => [
'url' => '', // We request the index, that is why its empty
'bypass-standard-error-handling' => ['404']
],
'interpretation' => [
['success', 'body', 'equals', '1'],
['failure', 'body', 'equals', '0'],
['failure', 'status-code', 'equals', '404'], // "index.html" might not be set to index
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace HtaccessCapabilityTester\Testers;
/**
* Class for testing if Header works
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class HeaderSetTester extends CustomTester
{
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$htaccessFile = <<<'EOD'
<IfModule mod_headers.c>
Header set X-Response-Header-Test: test
</IfModule>
EOD;
$test = [
'subdir' => 'header-set',
'files' => [
['.htaccess', $htaccessFile],
['request-me.txt', "hi"],
],
'request' => 'request-me.txt',
'interpretation' => [
['success', 'headers', 'contains-key-value', 'X-Response-Header-Test', 'test'],
['failure'],
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace HtaccessCapabilityTester\Testers\Helpers;
use \HtaccessCapabilityTester\HttpResponse;
use \HtaccessCapabilityTester\TestResult;
use \HtaccessCapabilityTester\Testers\AbstractTester;
/**
* Class for interpreting responses using a defined interpretation table.
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class ResponseInterpreter
{
/**
* Parse status string (failure | success | inconclusive) to bool|null.
*
* @param string $statusString (failure | success | inconclusive)
* @return bool|null
*/
private static function parseStatusString($statusString)
{
$status = null;
switch ($statusString) {
case 'failure':
$status = false;
break;
case 'inconclusive':
$status = null;
break;
case 'success':
$status = true;
break;
}
return $status;
}
/**
* Interpret headers line
*
* @param HttpResponse $response
* @param string $operator (has-key | )
* @param string $fieldName field name of the header
* @param string $fieldValue (optional) field value to look for. Only required when
* operator is "contains-key-value" or "not-contains-key-value"
* @return bool true if the condition matches, false otherwise
*/
private static function evaluateHeadersLine($response, $operator, $fieldName, $fieldValue)
{
switch ($operator) {
case 'contains-key':
return $response->hasHeader($fieldName);
case 'not-contains-key':
return (!($response->hasHeader($fieldName)));
case 'contains-key-value':
return $response->hasHeaderValue($fieldName, $fieldValue);
case 'not-contains-key-value':
return (!($response->hasHeaderValue($fieldName, $fieldValue)));
}
return false;
}
/**
* Interpret string line (body or status-code)
*
* @param HttpResponse $response
* @param string $property ("body" or "status-code")
* @param string $operator (is-empty | equals | not-equals | begins-with)
* @param string $arg1 (only required for some operators)
*
* @return bool true if the condition matches, false otherwise
*/
private static function evaluateStringLine($response, $property, $operator, $arg1)
{
$val = '';
switch ($property) {
case 'status-code':
$val = $response->statusCode;
break;
case 'body':
$val = $response->body;
break;
}
switch ($operator) {
case 'is-empty':
return ($val == '');
case 'equals':
return ($val == $arg1);
case 'not-equals':
return ($val != $arg1);
case 'begins-with':
return (strpos($val, $arg1) === 0);
}
return false;
}
/**
* Interpret line.
*
* @param HttpResponse $response
* @param array $line
*
* @return TestResult|null If the condition matches, a TestResult is returned, otherwise null
*/
private static function interpretLine($response, $line)
{
// ie:
// ['inconclusive', 'body', 'is-empty'],
// ['failure', 'statusCode', 'equals', '500']
// ['success', 'headers', 'contains-key-value', 'X-Response-Header-Test', 'test'],
$status = self::parseStatusString($line[0]);
if (!isset($line[1])) {
return new TestResult($status, '');
}
$propertyToExamine = $line[1];
$operator = $line[2];
$arg1 = (isset($line[3]) ? $line[3] : '');
$arg2 = (isset($line[4]) ? $line[4] : '');
if ($propertyToExamine == 'headers') {
$match = self::evaluateHeadersLine($response, $operator, $arg1, $arg2);
} else {
$match = self::evaluateStringLine($response, $propertyToExamine, $operator, $arg1);
}
if ($match) {
$reason = $propertyToExamine . ' ' . $operator;
if (isset($line[3])) {
$reason .= ' "' . implode('" "', array_slice($line, 3)) . '"';
}
/*
if (($propertyToExamine == 'status-code') && ($operator == 'not-equals') && (gettype($val) == 'string')) {
$reason .= ' - it was: ' . $val;
}*/
return new TestResult($status, $reason);
}
return null;
}
/**
* Interpret a response using an interpretation table.
*
* @param HttpResponse $response
* @param array $interpretationTable
*
* @return TestResult If there is no match, the test result will have status = false and
* info = "no-match".
*/
public static function interpret($response, $interpretationTable)
{
foreach ($interpretationTable as $i => $line) {
$testResult = self::interpretLine($response, $line);
if (!is_null($testResult)) {
return $testResult;
}
}
return new TestResult(null, 'no-match');
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace HtaccessCapabilityTester\Testers;
use \HtaccessCapabilityTester\HtaccessCapabilityTester;
use \HtaccessCapabilityTester\TestResult;
/**
* Class for testing if .htaccess files are processed
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class HtaccessEnabledTester extends AbstractTester
{
/**
* Child classes must implement this method, which tells which subdir the
* test files are to be put.
*
* @return string A subdir for the test files
*/
public function getSubDir()
{
return 'htaccess-enabled';
}
/**
* Register the test files using the "registerTestFile" method
*
* @return void
*/
public function registerTestFiles()
{
// No test files for this test
}
/**
* Run the test.
*
* @param string $baseDir Directory on the server where the test files can be put
* @param string $baseUrl The base URL of the test files
*
* @return TestResult Returns a test result
*/
public function run($baseDir, $baseUrl)
{
$this->prepareForRun($baseDir, $baseUrl);
/*
PS: We could implement this as a definition:
- [success, serverSignatureWorks, is-success]
- [success, contentDigestWorks, is-success]
- [failure, serverSignatureWorks, is-failure]
- [success, canCrash, is-success]
*/
$status = null;
$info = '';
$hct = $this->getHtaccessCapabilityTester();
// If we can find anything that works, well the .htaccess must have been proccesed!
if ($hct->serverSignatureWorks() // Override: None, Status: Core, REQUIRES PHP
|| $hct->contentDigestWorks() // Override: Options, Status: Core
|| $hct->addTypeWorks() // Override: FileInfo, Status: Base, Module: mime
|| $hct->directoryIndexWorks() // Override: Indexes, Status: Base, Module: mod_dir
|| $hct->rewriteWorks() // Override: FileInfo, Status: Extension, Module: rewrite
|| $hct->headerSetWorks() // Override: FileInfo, Status: Extension, Module: headers
) {
$status = true;
} else {
// The serverSignatureWorks() test is special because if it comes out as a failure,
// we can be *almost* certain that the .htaccess has been completely disabled
$serverSignatureWorks = $hct->serverSignatureWorks();
if ($serverSignatureWorks === false) {
$status = false;
$info = 'ServerSignature directive does not work - and it is in core';
} else {
// Last bullet in the gun:
// Try an .htaccess with syntax errors in it.
// (we do this lastly because it may generate an entry in the error log)
$crashTestResult = $hct->crashTest('aoeu', 'htaccess-enabled-malformed-htaccess');
if (is_null($crashTestResult)) {
// Two scenarios:
// 1: All requests fails (without response code)
// 2: The crash test could not figure it out (ie if even innocent requests crashes)
$status = null;
$info = 'all requests fails (even innocent ones)';
} elseif ($crashTestResult === false) {
// It crashed, - which means .htaccess is processed!
$status = true;
$info = 'syntax error in an .htaccess causes crash';
} else {
// It did not crash. So the .htaccess is not processed, as syntax errors
// makes servers crash
$status = false;
$info = 'syntax error in an .htaccess does not cause crash';
}
}
}
return new TestResult($status, $info);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace HtaccessCapabilityTester\Testers;
use \HtaccessCapabilityTester\TestResult;
/**
* Class for testing if an innocent request for a txt file succeeds
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class InnocentRequestTester extends CustomTester
{
public function __construct()
{
$test = [
'subdir' => 'innocent-request',
'files' => [
['request-me.txt', 'thank you my dear'],
],
'request' => [
'url' => 'request-me.txt',
'bypass-standard-error-handling' => ['all']
],
'interpretation' => [
['success', 'status-code', 'equals', '200'],
['inconclusive', 'status-code', 'equals', '0'],
['inconclusive', 'status-code', 'equals', '403'],
['inconclusive', 'status-code', 'equals', '404'],
['failure'],
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,369 @@
<?php
namespace HtaccessCapabilityTester\Testers;
use \HtaccessCapabilityTester\TestResult;
/**
* Class for testing if a module is loaded.
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class ModuleLoadedTester extends AbstractTester
{
/* @var string A valid Apache module name (ie "rewrite") */
protected $moduleName;
/**
* Constructor.
*
* @return void
*/
public function __construct($moduleName)
{
$this->moduleName = $moduleName;
}
/**
* Child classes must implement this method, which tells which subdir the
* test files are to be put.
*
* @return string A subdir for the test files
*/
public function getSubDir()
{
return 'module-loaded/' . $this->moduleName;
}
/**
* Register the test files using the "registerTestFile" method
*
* @return void
*/
public function registerTestFiles()
{
// No test files for this test
}
private function getServerSignatureBasedTest()
{
// Test files, method : Using ServerSignature
// --------------------------------------------------
// Requires (in order not to be inconclusive):
// - Override: All
// - Status: Core
// - Directives: ServerSignature, IfModule
// - PHP?: Yes
$php = <<<'EOD'
<?php
if (isset($_SERVER['SERVER_SIGNATURE']) && ($_SERVER['SERVER_SIGNATURE'] != '')) {
echo 1;
} else {
echo 0;
}
EOD;
$htaccess = <<<'EOD'
# The beauty of this trick is that ServerSignature is available in core.
# (it requires no modules and cannot easily be made forbidden)
# However, it requires PHP to check for the effect
ServerSignature Off
<IfModule mod_xxx.c>
ServerSignature On
</IfModule>
EOD;
$htaccess = str_replace('mod_xxx', 'mod_' . $this->moduleName, $htaccess);
return [
'subdir' => $this->getSubDir() . '/server-signature',
'files' => [
['.htaccess', $htaccess],
['test.php', $php],
],
'request' => 'test.php',
'interpretation' => [
['success', 'body', 'equals', '1'],
['failure', 'body', 'equals', '0'],
// This time we do not fail for 500 because it is very unlikely that any of the
// directives used are forbidden
]
];
}
/**
* @return array
*/
private function getRewriteBasedTest()
{
// Test files, method: Using Rewrite
// --------------------------------------------------
// Requires (in order not to be inconclusive)
// - Module: mod_rewrite
// - Override: FileInfo
// - Directives: RewriteEngine, RewriteRule and IfModule
// - PHP?: No
$htaccess = <<<'EOD'
RewriteEngine On
<IfModule mod_xxx.c>
RewriteRule ^request-me\.txt$ 1.txt [L]
</IfModule>
<IfModule !mod_xxx.c>
RewriteRule ^request-me\.txt$ 0.txt [L]
</IfModule>
EOD;
$htaccess = str_replace('mod_xxx', 'mod_' . $this->moduleName, $htaccess);
return [
'subdir' => $this->getSubDir() . '/rewrite',
'files' => [
['.htaccess', $htaccess],
['0.txt', '0'],
['1.txt', '1'],
['request-me.txt', 'Redirect failed even though rewriting has been proven to work. Strange!'],
],
'request' => 'request-me.txt',
'interpretation' => [
['success', 'body', 'equals', '1'],
['failure', 'body', 'equals', '0'],
//['inconclusive', 'status-code', 'not-equals', '200'],
]
];
}
/**
* @return array
*/
private function getHeaderSetBasedTest()
{
// Test files, method: Using Response Header
// --------------------------------------------------
// Requires (in order not to be inconclusive)
// - Module: mod_headers
// - Override: FileInfo
// - Directives: Header and IfModule
// - PHP?: No
$htaccess = <<<'EOD'
<IfModule mod_xxx.c>
Header set X-Response-Header-Test: 1
</IfModule>
<IfModule !mod_xxx.c>
Header set X-Response-Header-Test: 0
</IfModule>
EOD;
$htaccess = str_replace('mod_xxx', 'mod_' . $this->moduleName, $htaccess);
return [
'subdir' => $this->getSubDir() . '/header-set',
'files' => [
['.htaccess', $htaccess],
['request-me.txt', 'thanks'],
],
'request' => 'request-me.txt',
'interpretation' => [
['success', 'headers', 'contains-key-value', 'X-Response-Header-Test', '1'],
['failure', 'headers', 'contains-key-value', 'X-Response-Header-Test', '0'],
]
];
}
/**
* @return array
*/
private function getContentDigestBasedTest()
{
// Test files, method: Using ContentDigest
// --------------------------------------------------
//
// Requires (in order not to be inconclusive)
// - Module: None - its in core
// - Override: Options
// - Directives: ContentDigest
// - PHP?: No
$htaccess = <<<'EOD'
<IfModule mod_xxx.c>
ContentDigest On
</IfModule>
<IfModule !mod_xxx.c>
ContentDigest Off
</IfModule>
EOD;
$htaccess = str_replace('mod_xxx', 'mod_' . $this->moduleName, $htaccess);
return [
'subdir' => $this->getSubDir() . '/content-digest',
'files' => [
['.htaccess', $htaccess],
['request-me.txt', 'thanks'],
],
'request' => 'request-me.txt',
'interpretation' => [
['success', 'headers', 'contains-key', 'Content-MD5'],
['failure', 'headers', 'not-contains-key', 'Content-MD5'],
]
];
}
/**
* @return array
*/
private function getDirectoryIndexBasedTest()
{
// Test files, method: Using DirectoryIndex
// --------------------------------------------------
//
// Requires (in order not to be inconclusive)
// - Module: mod_dir (Status: Base)
// - Override: Indexes
// - Directives: DirectoryIndex
// - PHP?: No
$htaccess = <<<'EOD'
<IfModule mod_xxx.c>
DirectoryIndex 1.html
</IfModule>
<IfModule !mod_xxx.c>
DirectoryIndex 0.html
</IfModule>
EOD;
$htaccess = str_replace('mod_xxx', 'mod_' . $this->moduleName, $htaccess);
return [
'subdir' => $this->getSubDir() . '/directory-index',
'files' => [
['.htaccess', $htaccess],
['0.html', '0'],
['1.html', '1'],
],
'request' => '', // empty - in order to request the index
'interpretation' => [
['success', 'body', 'equals', '1'],
['failure', 'body', 'equals', '0'],
]
];
}
/**
* @return array
*/
private function getAddTypeBasedTest()
{
// Test files, method: Using AddType
// --------------------------------------------------
//
// Requires (in order not to be inconclusive)
// - Module: mod_mime
// - Override: FileInfo
// - Directives: AddType and IfModule
// - PHP?: No
$htaccess = <<<'EOD'
<IfModule mod_xxx.c>
AddType image/gif .test
</IfModule>
<IfModule !mod_xxx.c>
AddType image/jpeg .test
</IfModule>
EOD;
$htaccess = str_replace('mod_xxx', 'mod_' . $this->moduleName, $htaccess);
return [
'subdir' => $this->getSubDir() . '/add-type',
'files' => [
['.htaccess', $htaccess],
['request-me.test', 'hi'],
],
'request' => 'request-me.test',
'interpretation' => [
['success', 'headers', 'contains-key-value', 'Content-Type', 'image/gif'],
['failure', 'headers', 'contains-key-value', 'Content-Type', 'image/jpeg'],
]
];
}
/**
* @return bool|null
*/
private function run2()
{
$hct = $this->getHtaccessCapabilityTester();
$testResult = $hct->customTest($this->getServerSignatureBasedTest());
if (!is_null($testResult)) {
// PHP
return $testResult;
}
if ($hct->contentDigestWorks()) {
// Override: Options
return $hct->customTest($this->getContentDigestBasedTest());
}
if ($hct->addTypeWorks()) {
// Override: FileInfo, Status: Base (mod_mime)
return $hct->customTest($this->getAddTypeBasedTest());
}
if ($hct->directoryIndexWorks()) {
// Override: Indexes, Status: Base (mod_dir)
return $hct->customTest($this->getDirectoryIndexBasedTest());
}
if ($hct->rewriteWorks()) {
// Override: FileInfo, Module: mod_rewrite
return $hct->customTest($this->getRewriteBasedTest());
}
if ($hct->headerSetWorks()) {
//Override: FileInfo, Module: mod_headers
return $hct->customTest($this->getHeaderSetBasedTest());
}
return null;
}
/**
* Run the test.
*
* @param string $baseDir Directory on the server where the test files can be put
* @param string $baseUrl The base URL of the test files
*
* @return TestResult Returns a test result
*/
public function run($baseDir, $baseUrl)
{
$this->prepareForRun($baseDir, $baseUrl);
$hct = $this->getHtaccessCapabilityTester();
$htaccessEnabledTest = $hct->htaccessEnabled();
if ($htaccessEnabledTest === false) {
return new TestResult(false, '.htaccess files are ignored');
} elseif (is_null($htaccessEnabledTest)) {
// We happen to know that if that test cannot establish anything,
// then none of the usual weapons works - we can surrender right away
return new TestResult(null, 'no methods available - we surrender early');
}
$status = $this->run2();
if (is_null($status)) {
return new TestResult(null, 'no methods worked');
} else {
return new TestResult($status, '');
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace HtaccessCapabilityTester\Testers;
/**
* Class for testing if an environment variable can be set in a rewrite rule and received in PHP.
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class PassInfoFromRewriteToScriptThroughEnvTester extends CustomTester
{
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$htaccessFile = <<<'EOD'
<IfModule mod_rewrite.c>
# Testing if we can pass environment variable from .htaccess to script in a RewriteRule
# We pass document root, because that can easily be checked by the script
RewriteEngine On
RewriteRule ^test\.php$ - [E=PASSTHROUGHENV:%{DOCUMENT_ROOT},L]
</IfModule>
EOD;
$phpFile = <<<'EOD'
<?php
/**
* Get environment variable set with mod_rewrite module
* Return false if the environment variable isn't found
*/
function getEnvPassedInRewriteRule($envName) {
// Environment variables passed through the REWRITE module have "REWRITE_" as a prefix
// (in Apache, not Litespeed, if I recall correctly).
// Multiple iterations causes multiple REWRITE_ prefixes, and we get many environment variables set.
// We simply look for an environment variable that ends with what we are looking for.
// (so make sure to make it unique)
$len = strlen($envName);
foreach ($_SERVER as $key => $item) {
if (substr($key, -$len) == $envName) {
return $item;
}
}
return false;
}
$result = getEnvPassedInRewriteRule('PASSTHROUGHENV');
if ($result === false) {
echo '0';
exit;
}
echo ($result == $_SERVER['DOCUMENT_ROOT'] ? '1' : '0');
EOD;
$test = [
'subdir' => 'pass-info-from-rewrite-to-script-through-env',
'files' => [
['.htaccess', $htaccessFile],
['test.php', $phpFile],
],
'request' => 'test.php',
'interpretation' => [
['success', 'body', 'equals', '1'],
['failure', 'body', 'equals', '0'],
['inconclusive', 'body', 'begins-with', '<' . '?php'],
['inconclusive']
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace HtaccessCapabilityTester\Testers;
/**
* Say you have a rewrite rule that points to a PHP script and you would like to pass some information
* along to the PHP. Usually, you will just pass it in the query string. But this won't do if the information
* is sensitive. In that case, there are some tricks available. The trick being tested here sets tells the
* RewriteRule directive to set an environment variable which a RequestHeader directive picks up on and passes
* on to the script in a request header.
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class PassInfoFromRewriteToScriptThroughRequestHeaderTester extends CustomTester
{
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$htaccessFile = <<<'EOD'
<IfModule mod_rewrite.c>
RewriteEngine On
# We pass document root, because that can easily be checked by the script
RewriteRule ^test\.php$ - [E=PASSTHROUGHHEADER:%{DOCUMENT_ROOT},L]
<IfModule mod_headers.c>
RequestHeader set PASSTHROUGHHEADER "%{PASSTHROUGHHEADER}e" env=PASSTHROUGHHEADER
</IfModule>
</IfModule>
EOD;
$phpFile = <<<'EOD'
<?php
if (isset($_SERVER['HTTP_PASSTHROUGHHEADER'])) {
echo ($_SERVER['HTTP_PASSTHROUGHHEADER'] == $_SERVER['DOCUMENT_ROOT'] ? 1 : 0);
exit;
}
echo '0';
EOD;
$test = [
'subdir' => 'pass-info-from-rewrite-to-script-through-request-header',
'files' => [
['.htaccess', $htaccessFile],
['test.php', $phpFile],
],
'request' => 'test.php',
'interpretation' => [
['success', 'body', 'equals', '1'],
['failure', 'body', 'equals', '0'],
['inconclusive', 'body', 'begins-with', '<' . '?php'],
['inconclusive']
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace HtaccessCapabilityTester\Testers;
/**
* Class for testing if RequestHeader works
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class RequestHeaderTester extends CustomTester
{
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$htaccessFile = <<<'EOD'
<IfModule mod_headers.c>
# Certain hosts seem to strip non-standard request headers,
# so we use a standard one to avoid a false negative
RequestHeader set User-Agent "request-header-test"
</IfModule>
EOD;
$phpFile = <<<'EOD'
<?php
if (isset($_SERVER['HTTP_USER_AGENT'])) {
echo (($_SERVER['HTTP_USER_AGENT'] == 'request-header-test') ? "1" : "0");
} else {
echo "0";
}
EOD;
// PS:
// There is a little edge case: When .htaccess is disabled AND phps are either not processed
// or access is denied. This ought to return *failure*, but it currently returns *inconclusive*.
$test = [
'subdir' => 'request-header',
'files' => [
['.htaccess', $htaccessFile],
['test.php', $phpFile],
],
'request' => 'test.php',
'interpretation' => [
['success', 'body', 'equals', '1'],
['failure', 'body', 'equals', '0'],
['inconclusive', 'body', 'begins-with', '<' . '?php'],
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace HtaccessCapabilityTester\Testers;
/**
* Class for testing if rewriting works at the tested location.
*
* The tester reports success when:
* - a rewrite is proven to be working
*
* The tester reports failure when:
* - Server does not have mod_rewrite installed
* - Server is set up to ignore .htaccess files in the directory
* - Server disallows any the following directives in the directory: RewriteEngine, Rewrite, IfModule
* (if disallowed, the result is either a 500 Internal Server Error or that the directive is
* ignored, depending on whether Nonfatal is set)
* - The request results in a 500 Internal Server Error due to another problem than a disallowed
* directive (this is, there is a risk for a false negative)
*
* The test works by creating an .htaccess which redirects requests to "0.txt"
* to "1.txt" and then requesting "0.txt".
*
* Notes:
* - The test might result in the following being written to the error log:
* "RewriteEngine not allowed here"
* - We are not redirecting to a php, because that would additionally require phps
* to be run in that directory
* - We are wrapping the .htaccess directives in a "<IfModule mod_rewrite.c>" and therefore this test
* also relies on the IfModule directive being allowed. It probably usually is, as it is harmless.
* Also, it is good practice to use it, so in most cases it is good that this is checked
* too. Actually, the <IfModule> wrap isn't neccessary for our test to work, as the test
* identifies a 500 Internal Error as test failure. However, not having the wrap would
* cause the test to generate an entry in the error log when mod_rewrite isn't installed
* (regardless if overrides are configured to Nonfatal or not):
* "Invalid command 'RewriteEngine', perhaps misspelled or defined by a module not included
* in the server configuration"
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class RewriteTester extends CustomTester
{
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$htaccessFile = <<<'EOD'
# Testing for mod_rewrite
# -----------------------
# If mod_rewrite is enabled, redirect to 1.txt, which returns "1".
# If mod_rewrite is disabled, the rewriting fails, and we end at 0.txt, which returns "0".
#
# Notes:
# - We are not redirecting to a php, because that would additionally require phps
# to be run in that directory
# - We are wrapping it in a "<IfModule mod_rewrite.c>" and therefore this test also relies
# on the IfModule directive being allowed. It probably usually is, as it is harmless.
# Also, it is good practice to use it, so in most cases it is good that this is checked
# too. Actually, the <IfModule> wrap isn't neccessary for our test to work, as the test
# identifies a 500 Internal Error as test failure. However, not having the wrap would
# cause the test to generate an entry in the error log when mod_rewrite isn't installed
# (regardless if configured to Nonfatal or not): "Invalid command 'RewriteEngine', perhaps
# misspelled or defined by a module not included
# in the server configuration"
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^0\.txt$ 1\.txt [L]
</IfModule>
EOD;
$test = [
'subdir' => 'rewrite',
'files' => [
['.htaccess', $htaccessFile],
['0.txt', "0"],
['1.txt', "1"]
],
'request' => '0.txt',
'interpretation' => [
['success', 'body', 'equals', '1'],
['failure', 'body', 'equals', '0'],
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace HtaccessCapabilityTester\Testers;
/**
* Class for testing if ServerSignature works
*
* Testing the ServerSignature directive is of interest because the directive is a core feature.
* If a core feature doesn't work, well, it it would seem that .htaccess files are disabled completely.
* The test is thus special. If it returns *failure* it is highly probable that the .htaccess file has
* not been read.
*
* Unfortunately, the test requires PHP to examine if a server variable has been set. So the test is not
* unlikely to come out inconclusive due to a 403 Forbidden.
*
* Note that the test assumes that the ServerSignature directive has not been disallowed even though
* it is technically possible to do so by setting *AllowOverride* to *None* and by setting *AllowOverrideList*
* to a list that does not include *ServerSignature*.
*
* @package HtaccessCapabilityTester
* @author Bjørn Rosell <it@rosell.dk>
* @since Class available since 0.7
*/
class ServerSignatureTester extends CustomTester
{
/**
* Constructor.
*
* @return void
*/
public function __construct()
{
$phpOn = <<<'EOD'
<?php
if (isset($_SERVER['SERVER_SIGNATURE']) && ($_SERVER['SERVER_SIGNATURE'] != '')) {
echo 1;
} else {
echo 0;
}
EOD;
$phpOff = <<<'EOD'
<?php
if (isset($_SERVER['SERVER_SIGNATURE']) && ($_SERVER['SERVER_SIGNATURE'] != '')) {
echo 0;
} else {
echo 1;
}
EOD;
// PS:
// There is a little edge case: When .htaccess is disabled AND phps are either not processed
// or access is denied. This ought to return *failure*, but it currently returns *inconclusive*.
$test = [
'subdir' => 'server-signature',
'subtests' => [
[
'subdir' => 'on',
'files' => [
['.htaccess', 'ServerSignature On'],
['test.php', $phpOn],
],
'request' => [
'url' => 'test.php',
],
'interpretation' => [
['inconclusive', 'body', 'isEmpty'],
['inconclusive', 'status-code', 'not-equals', '200'],
['failure', 'body', 'equals', '0'],
],
],
[
'subdir' => 'off',
'files' => [
['.htaccess', 'ServerSignature Off'],
['test.php', $phpOff],
],
'request' => 'test.php',
'interpretation' => [
['inconclusive', 'body', 'isEmpty'],
['success', 'body', 'equals', '1'],
['failure', 'body', 'equals', '0'],
['inconclusive']
]
]
]
];
parent::__construct($test);
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace HtaccessCapabilityTester\Tests;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\HttpRequesterInterface;
use HtaccessCapabilityTester\TestFilesLineUpperInterface;
use HtaccessCapabilityTester\TestResult;
use HtaccessCapabilityTester\TestResultCache;
use HtaccessCapabilityTester\Testers\AbstractTester;
class FakeServer implements TestFilesLineUpperInterface, HttpRequesterInterface
{
/** @var array Files on the server */
private $files;
/** @var array Files as a map, by filename */
private $filesMap;
/** @var bool If .htaccess processing is disabled */
private $htaccessDisabled = false;
/** @var bool If all directives should be disallowed (but .htaccess still read) */
private $disallowAllDirectives = false;
/** @var bool If server should go fatal about forbidden directives */
private $fatal = false;
/** @var bool If all requests should crash! (500) */
private $crashAll = false;
/** @var bool If access is denied for all requests */
private $accessAllDenied = false;
/** @var bool Returns the php text file rather than "Sorry, this server cannot process PHP!" */
private $handlePHPasText = false;
/** @var bool If all requests fail (without response code) */
private $failAll = false;
/** @var array Predefined responses for certain urls */
private $responses;
public function lineUp($files)
{
$this->files = $files;
$this->filesMap = [];
foreach ($files as $file) {
list($filename, $content) = $file;
$this->filesMap[$filename] = $content;
}
//$m = new SetRequestHeaderTester();
//$m->putFiles('');
//print_r($files);
}
public function makeHttpRequest($url)
{
$body = '';
$statusCode = '200';
$headers = [];
if ($this->failAll) {
return new HttpResponse('', '0', []);
}
//echo 'Fakeserver request:' . $url . "\n";
if (isset($this->responses[$url])) {
//echo 'predefined: ' . $url . "\n";
return $this->responses[$url];
}
if ($this->crashAll) {
return new HttpResponse('', '500', []);
}
if (($this->disallowAllDirectives) && ($this->fatal)) {
$urlToHtaccessInSameFolder = dirname($url) . '/.htaccess';
$doesFolderContainHtaccess = isset($this->filesMap[$urlToHtaccessInSameFolder]);
if ($doesFolderContainHtaccess) {
return new HttpResponse('', '500', []);
}
}
if ($this->accessAllDenied) {
// TODO: what body?
return new HttpResponse('', '403', []);
}
//$simplyServeRequested = ($this->htaccessDisabled || ($this->disallowAllDirectives && (!$this->fatal)));
// Simply return the file that was requested
if (isset($this->filesMap[$url])) {
$isPhpFile = (strrpos($url, '.php') == strlen($url) - 4);
if ($isPhpFile && ($this->handlePHPasText)) {
return new HttpResponse('Sorry, this server cannot process PHP!', '200', []); ;
} else {
return new HttpResponse($this->filesMap[$url], '200', []); ;
}
} else {
return new HttpResponse('Not found', '404', []);
}
//return new HttpResponse('Not found', '404', []);
}
/**
* Disallows all directives, but do still process .htaccess.
*
* In essence: Fail, if the folder contains an .htaccess file
*
* @param string $fatal fatal|nonfatal
*/
public function disallowAllDirectives($fatal)
{
$this->disallowAllDirectives = true;
$this->fatal = ($fatal = 'fatal');
}
public function disableHtaccess()
{
$this->htaccessDisabled = true;
}
public function denyAllAccess()
{
$this->accessAllDenied = true;
}
public function makeAllCrash()
{
$this->crashAll = true;
}
public function failAllRequests()
{
$this->failAll = true;
}
public function handlePHPasText()
{
$this->handlePHPasText = true;
}
// TODO: denyAccessToPHP
/**
* @param array $responses
*/
public function setResponses($responses)
{
$this->responses = $responses;
}
public function connectHCT($hct)
{
TestResultCache::clear();
$hct->setTestFilesLineUpper($this);
$hct->setHttpRequester($this);
}
/**
* @param AbstractTester $tester
* @return TestResult
*/
public function runTester($tester)
{
TestResultCache::clear();
$tester->setTestFilesLineUpper($this);
$tester->setHttpRequester($this);
return $tester->run('', '');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace HtaccessCapabilityTester\Tests;
use HtaccessCapabilityTester\HtaccessCapabilityTester;
class Helper
{
public static function getTesterUsingFakeServer($fakeServer)
{
$hct = new HtaccessCapabilityTester('', '');
$hct->setTestFilesLineUpper($fakeServer);
$hct->setHttpRequester($fakeServer);
return $hct;
}
}

View File

@@ -0,0 +1,142 @@
<?php
/*
subdir: rewrite
files:
- filename: '.htaccess'
content: |
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^0\.txt$ 1\.txt [L]
</IfModule>
- filename: '0.txt'
content: '0'
- filename: '1.txt'
content: '1'
request:
url: '0.txt'
interpretation:
- [success, body, equals, '1']
- [failure, body, equals, '0']
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
forbidden directives (fatal) | failure
access denied | inconclusive (it might be allowed to other files)
directive has no effect | failure
| success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HtaccessCapabilityTester;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class HtaccessCapabilityTesterTest extends BasisTestCase
{
public function testHeaderSetWorksSuccess()
{
$hct = new HtaccessCapabilityTester('', '');
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/header-set/request-me.txt' => new HttpResponse(
'hi',
'200',
['X-Response-Header-Test' => 'test']
)
]);
$fakeServer->connectHCT($hct);
$this->assertTrue($hct->headerSetWorks());
}
public function testRequestHeaderWorksSuccess()
{
$hct = new HtaccessCapabilityTester('', '');
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/request-header/test.php' => new HttpResponse('1', '200', [])
]);
$fakeServer->connectHCT($hct);
$this->assertTrue($hct->requestHeaderWorks());
}
public function testRequestHeaderWorksFailure1()
{
$hct = new HtaccessCapabilityTester('', '');
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/request-header/test.php' => new HttpResponse('0', '200', [])
]);
$fakeServer->connectHCT($hct);
$this->assertFalse($hct->requestHeaderWorks());
}
public function testPassingThroughRequestHeaderSuccess()
{
$hct = new HtaccessCapabilityTester('', '');
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/pass-info-from-rewrite-to-script-through-request-header/test.php' =>
new HttpResponse('1', '200', [])
]);
$fakeServer->connectHCT($hct);
$this->assertTrue($hct->passingInfoFromRewriteToScriptThroughRequestHeaderWorks());
}
public function testPassingThroughEnvSuccess()
{
$hct = new HtaccessCapabilityTester('', '');
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/pass-info-from-rewrite-to-script-through-env/test.php' =>
new HttpResponse('1', '200', [])
]);
$fakeServer->connectHCT($hct);
$this->assertTrue($hct->passingInfoFromRewriteToScriptThroughEnvWorks());
}
public function testModuleLoadedWhenNotLoaded()
{
$hct = new HtaccessCapabilityTester('', '');
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/rewrite/0.txt' => new HttpResponse('1', '200', []),
'/module-loaded/setenvif/rewrite/request-me.txt' => new HttpResponse('0', '200', []),
]);
$fakeServer->connectHCT($hct);
$this->assertFalse($hct->moduleLoaded('setenvif'));
}
public function testModuleLoadedWhenLoaded()
{
$hct = new HtaccessCapabilityTester('', '');
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/rewrite/0.txt' => new HttpResponse('1', '200', []),
'/module-loaded/setenvif/rewrite/request-me.txt' => new HttpResponse('1', '200', []),
]);
$fakeServer->connectHCT($hct);
$this->assertTrue($hct->moduleLoaded('setenvif'));
}
//
}

View File

@@ -0,0 +1,51 @@
<?php
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class HttpResponseTest extends TestCase
{
public function test1()
{
$r = new HttpResponse('hi', '200', [
'x-test' => 'test'
]);
$this->assertTrue($r->hasHeader('x-test'));
$this->assertTrue($r->hasHeader('X-Test'));
$this->assertTrue($r->hasHeaderValue('X-Test', 'test'));
}
public function test2()
{
$r = new HttpResponse('hi', '200', [
'x-test1' => 'value1, value2',
'x-test2' => 'value1,value2'
]);
$this->assertTrue($r->hasHeaderValue('X-Test1', 'value2'));
$this->assertTrue($r->hasHeaderValue('X-Test2', 'value2'));
}
public function test3()
{
$r = new HttpResponse('hi', '200', [
'content-md5' => 'aaoeu'
]);
$this->assertTrue($r->hasHeader('Content-MD5'));
$this->assertTrue($r->hasHeader('content-md5'));
}
public function test4()
{
$r = new HttpResponse('hi', '200', [
'Content-MD5' => 'aaoeu'
]);
$this->assertTrue($r->hasHeader('Content-MD5'));
$this->assertTrue($r->hasHeader('content-md5'));
}
//
}

View File

@@ -0,0 +1,94 @@
<?php
/*
subdir: add-type
files:
- filename: '.htaccess'
content: |
<IfModule mod_mime.c>
AddType image/gif .test
</IfModule>
- filename: 'request-me.test'
content: 'hi'
request:
url: 'request-me.test'
interpretation:
- ['success', 'headers', 'contains-key-value', 'Content-Type', 'image/gif']
- ['inconclusive', 'status-code', 'not-equals', '200']
- ['failure', 'headers', 'not-contains-key-value', 'Content-Type', 'image/gif']
----
Tested:
| Case | Test result
| ------------------------------ | ------------------
| .htaccess disabled | failure
| forbidden directives (fatal) | failure
| access denied | inconclusive
| directive has no effect | failure
| it works | success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\AddTypeTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class AddTypeTesterTest extends BasisTestCase
{
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new AddTypeTester());
$this->assertFailure($testResult);
}
public function testDisallowedDirectivesFatal()
{
$fakeServer = new FakeServer();
$fakeServer->disallowAllDirectives('fatal');
$testResult = $fakeServer->runTester(new AddTypeTester());
$this->assertFailure($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new AddTypeTester());
$this->assertInconclusive($testResult);
}
/**
* Test when the directive has no effect.
* This could happen when:
* - The directive is forbidden (non-fatal)
* - The module is not loaded
*/
public function testDirectiveHasNoEffect()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/add-type/request-me.test' => new HttpResponse('hi', '200', [])
]);
$testResult = $fakeServer->runTester(new AddTypeTester());
$this->assertFailure($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/add-type/request-me.test' => new HttpResponse('hi', '200', ['Content-Type' => 'image/gif'])
]);
$testResult = $fakeServer->runTester(new AddTypeTester());
$this->assertSuccess($testResult);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HtaccessCapabilityTester;
use HtaccessCapabilityTester\TestResult;
use HtaccessCapabilityTester\Testers\RewriteTester;
use HtaccessCapabilityTester\Testers\AbstractTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class BasisTestCase extends TestCase
{
protected function assertSuccess($testResult)
{
$this->assertTrue($testResult->status, $testResult->info);
}
protected function assertFailure($testResult)
{
$this->assertFalse($testResult->status, $testResult->info);
}
protected function assertInconclusive($testResult)
{
$this->assertNull($testResult->status, $testResult->info);
}
/**
*
* @param TestResult $testResult
* @param string $expectedResult failure|success|inconclusive
*
*/
/*
protected function assertTestResult($testResult, $expectedResult)
{
if ($expectedResult == 'failure') {
$this->assertFalse($testResult->status);
} elseif ($expectedResult == 'success') {
$this->assertTrue($testResult->status);
} elseif ($expectedResult == 'inconclusive') {
$this->assertNull($testResult->status);
}
}*/
/**
* @param AbstractTester $tester
* @param array $expectedBehaviour
* @param FakeServer $fakeServer
*/
/*
protected function behaviourOnFakeServer($tester, $expectedBehaviour, $fakeServer)
{
$tester->setTestFilesLineUpper($fakeServer);
$tester->setHttpRequester($fakeServer);
// $hct = Helper::getTesterUsingFakeServer($fakeServer);
if (isset($expectedBehaviour['htaccessDisabled'])) {
$fakeServer->disallowAllDirectives = true;
$testResult = $tester->run('', '');
$this->assertTestResult($testResult, );
$this->assertFailure($testResult->status);
}
}*/
}

View File

@@ -0,0 +1,135 @@
<?php
/*
subdir: content-digest
subtests:
- subdir: on
files:
- filename: '.htaccess'
content: |
ContentDigest On
- filename: 'request-me.txt'
content: 'hi'
request:
url: 'request-me.txt'
interpretation:
- ['failure', 'headers', 'not-contains-key', 'Content-MD5'],
- subdir: off
files:
- filename: '.htaccess'
content: |
ContentDigest Off
- filename: 'request-me.txt'
content: 'hi'
request:
url: 'request-me.txt'
interpretation:
- ['failure', 'headers', 'contains-key', 'Content-MD5']
- ['inconclusive', 'status-code', 'not-equals', '200']
- ['success', 'status-code', 'equals', '200']
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
forbidden directives (fatal) | failure (Required override: Options)
access denied | inconclusive (it might be allowed to other files)
directive has no effect | failure
| success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\ContentDigestTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class ContentDigestTesterTest extends BasisTestCase
{
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new ContentDigestTester());
$this->assertFailure($testResult);
}
public function testDisallowedDirectivesFatal()
{
$fakeServer = new FakeServer();
$fakeServer->disallowAllDirectives('fatal');
$testResult = $fakeServer->runTester(new ContentDigestTester());
$this->assertFailure($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new ContentDigestTester());
$this->assertInconclusive($testResult);
}
/**
* Test when the directive has no effect.
* This could happen when:
* - The directive is forbidden (non-fatal)
* - The module is not loaded
*
* Test no effect when server is setup to content-digest
*/
public function testDirectiveHasNoEffect1()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/content-digest/on/request-me.txt' => new HttpResponse('hi', '200', ['Content-MD5' => 'aaoeu']),
'/content-digest/off/request-me.txt' => new HttpResponse('hi', '200', ['Content-MD5' => 'aaoeu']),
]);
$testResult = $fakeServer->runTester(new ContentDigestTester());
$this->assertFailure($testResult);
}
/** Test no effect when server is setup NOT to content-digest
*/
public function testDirectiveHasNoEffect2()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/content-digest/on/request-me.txt' => new HttpResponse('hi', '200', []),
'/content-digest/off/request-me.txt' => new HttpResponse('hi', '200', []),
]);
$testResult = $fakeServer->runTester(new ContentDigestTester());
$this->assertFailure($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/content-digest/on/request-me.txt' => new HttpResponse(
'hi',
'200',
['Content-MD5' => 'aaoeu']
),
'/content-digest/off/request-me.txt' => new HttpResponse('hi', '200', [])
]);
$testResult = $fakeServer->runTester(new ContentDigestTester());
$this->assertSuccess($testResult);
}
public function testRequestFailure()
{
$fakeServer = new FakeServer();
$fakeServer->failAllRequests();
$testResult = $fakeServer->runTester(new ContentDigestTester());
$this->assertInconclusive($testResult);
}
}

View File

@@ -0,0 +1,114 @@
<?php
/*
subdir: 'crash-tester/xxx' # xxx is a subdir for the specific crash-test
subtests:
- subdir: the-suspect
files:
- filename: '.htaccess'
content: # the rules goes here
- filename: 'request-me.txt'
content: 'thanks'
request:
url: 'request-me.txt'
bypass-standard-error-handling': ['all']
interpretation:
- [success, body, equals, '1']
- [failure, body, equals, '0']
- [success, status-code, not-equals, '500']
- subdir: the-innocent
files:
- filename: '.htaccess'
content: '# I am no trouble'
- filename: 'request-me.txt'
content: 'thanks'
request:
url: 'request-me.txt'
bypass-standard-error-handling: ['all']
interpretation:
# The suspect crashed. But if the innocent crashes too, we cannot judge
[inconclusive, status-code, equals, '500']
# The innocent did not crash. The suspect is guilty!
[failure]
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | success! (nothing crashes)
access denied | success! (nothing crashes. In case there is both errors and
access denied, the response is 500. This is however
only tested on Apache 2.4.29)
all requests crash | inconclusive (even innocent request crashes means that we cannot
conclude that the rules are "crashy", or that they are not
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\CrashTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class CrashTesterTest extends BasisTestCase
{
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new CrashTester(''));
$this->assertSuccess($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new CrashTester(''));
$this->assertSuccess($testResult);
}
public function testWhenAllRequestsCrashes()
{
$fakeServer = new FakeServer();
$fakeServer->makeAllCrash();
$testResult = $fakeServer->runTester(new CrashTester(''));
$this->assertInconclusive($testResult);
}
public function testWhenAllRequestsCrashes2()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/crash-tester/test/the-suspect/request-me.txt' => new HttpResponse('', '500', []),
'/crash-tester/test/the-innocent/request-me.txt' => new HttpResponse('', '500', [])
]);
$testResult = $fakeServer->runTester(new CrashTester('aoeu', 'test'));
$this->assertInconclusive($testResult);
}
public function testWhenRequestCrashesButInnocentDoesNot()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/crash-tester/test/the-suspect/request-me.txt' => new HttpResponse('', '500', []),
'/crash-tester/test/the-innocent/request-me.txt' => new HttpResponse('thanks', '200', [])
]);
$testResult = $fakeServer->runTester(new CrashTester('aoeu', 'test'));
$this->assertFailure($testResult);
}
public function testRequestFailure()
{
$fakeServer = new FakeServer();
$fakeServer->failAllRequests();
$testResult = $fakeServer->runTester(new CrashTester('aoeu', 'test'));
$this->assertInconclusive($testResult);
}
}

View File

@@ -0,0 +1,100 @@
<?php
/*
subdir: directory-index
files:
- filename: '.htaccess'
content: |
<IfModule mod_dir.c>
DirectoryIndex index2.html
</IfModule>
- filename: 'index.html'
content: '0'
- filename: 'index2.html'
content: '1'
request:
url: '' # We request the index, that is why its empty
bypass-standard-error-handling: ['404']
interpretation:
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['failure', 'status-code', 'equals', '404'] # "index.html" might not be set to index
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
forbidden directives (fatal) | failure (highly unlikely, as it is part of core - but still possible)
access denied | inconclusive (it might be allowed to other files)
directive has no effect | failure
| success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\DirectoryIndexTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class DirectoryIndexTesterTest extends BasisTestCase
{
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new DirectoryIndexTester());
$this->assertFailure($testResult);
}
public function testDisallowedDirectivesFatal()
{
$fakeServer = new FakeServer();
$fakeServer->disallowAllDirectives('fatal');
$testResult = $fakeServer->runTester(new DirectoryIndexTester());
$this->assertFailure($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new DirectoryIndexTester());
$this->assertInconclusive($testResult);
}
/**
* Test when the directive has no effect.
* This could happen when:
* - The directive is forbidden (non-fatal)
* - The module is not loaded
*
*/
public function testDirectiveHasNoEffect()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/directory-index/' => new HttpResponse('0', '200', []),
]);
$testResult = $fakeServer->runTester(new DirectoryIndexTester());
$this->assertFailure($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/directory-index/' => new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(new DirectoryIndexTester());
$this->assertSuccess($testResult);
}
}

View File

@@ -0,0 +1,99 @@
<?php
/*
subdir: header-set
files:
- filename: '.htaccess'
content: |
<IfModule mod_headers.c>
Header set X-Response-Header-Test: test
</IfModule>
- filename: 'request-me.txt'
content: 'hi'
request:
url: 'request-me.txt'
interpretation:
- [success, headers, contains-key-value, 'X-Response-Header-Test', 'test'],
- [failure]
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
forbidden directives (fatal) | failure
access denied | inconclusive (it might be allowed to other files)
directive has no effect | failure
| success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\HeaderSetTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class HeaderSetTesterTest extends BasisTestCase
{
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new HeaderSetTester());
$this->assertFailure($testResult);
}
public function testDisallowedDirectivesFatal()
{
$fakeServer = new FakeServer();
$fakeServer->disallowAllDirectives('fatal');
$testResult = $fakeServer->runTester(new HeaderSetTester());
$this->assertFailure($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new HeaderSetTester());
$this->assertInconclusive($testResult);
}
/**
* Test when the directive has no effect.
* This could happen when:
* - The directive is forbidden (non-fatal)
* - The module is not loaded
*/
public function testDirectiveHasNoEffect()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/header-set/request-me.txt' => new HttpResponse('hi', '200', [])
]);
$testResult = $fakeServer->runTester(new HeaderSetTester());
$this->assertFailure($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/header-set/request-me.txt' => new HttpResponse(
'hi',
'200',
['X-Response-Header-Test' => 'test']
)
]);
$testResult = $fakeServer->runTester(new HeaderSetTester());
$this->assertSuccess($testResult);
}
}

View File

@@ -0,0 +1,124 @@
<?php
/*
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
access denied | inconclusive (it might be allowed to other files)
it works | success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\HtaccessEnabledTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class HtaccessEnabledTesterTest extends BasisTestCase
{
/**
* Test failure when server signature fails
*
*/
public function testSuccessServerSignatureFails()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/server-signature/on/test.php' => new HttpResponse('0', '200', []),
'/server-signature/off/test.php' => new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(new HtaccessEnabledTester());
$this->assertFailure($testResult);
}
/**
* Test success when server signature works.
*
*/
public function testSuccessServerSignatureSucceeds()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/server-signature/on/test.php' => new HttpResponse('1', '200', []),
'/server-signature/off/test.php' => new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(new HtaccessEnabledTester());
$this->assertSuccess($testResult);
}
/**
* Test success when setting a header works.
*/
public function testSuccessHeaderSetSucceeds()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/header-set/request-me.txt' => new HttpResponse(
'hi',
'200',
['X-Response-Header-Test' => 'test']
)
]);
$testResult = $fakeServer->runTester(new HtaccessEnabledTester());
$this->assertSuccess($testResult);
}
/**
* Test success when malformed .htaccess causes 500
*/
public function testSuccessMalformedHtaccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/crash-tester/htaccess-enabled-malformed-htaccess/the-suspect/request-me.txt' =>
new HttpResponse('', '500', []),
'/crash-test/htaccess-enabled-malformed-htaccess/the-innocent/request-me.txt' =>
new HttpResponse('thanks', '200', [])
]);
$testResult = $fakeServer->runTester(new HtaccessEnabledTester());
$this->assertSuccess($testResult);
}
/**
* Test failure when malformed .htaccess causes 500
*/
public function testFailureMalformedHtaccessDoesNotCauseCrash()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/crash-tester/htaccess-enabled-malformed-htaccess/the-suspect/request-me.txt' =>
new HttpResponse('thanks', '200', []),
'/crash-test/htaccess-enabled-malformed-htaccess/the-innocent/request-me.txt' =>
new HttpResponse('thanks', '200', [])
]);
$testResult = $fakeServer->runTester(new HtaccessEnabledTester());
$this->assertFailure($testResult);
}
/**
* Test inconclusive when all crashes
*/
public function testInconclusiveWhenAllCrashes()
{
$fakeServer = new FakeServer();
$fakeServer->makeAllCrash();
$testResult = $fakeServer->runTester(new HtaccessEnabledTester());
$this->assertInconclusive($testResult);
}
public function testRequestFailure()
{
$fakeServer = new FakeServer();
$fakeServer->failAllRequests();
$testResult = $fakeServer->runTester(new HtaccessEnabledTester());
$this->assertInconclusive($testResult);
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
subdir: innocent-request
files:
- filename: 'request-me.txt'
content: 'thank you my dear'
request:
url: 'request-me.txt'
bypass-standard-error-handling: 'all'
interpretation:
- ['success', 'status-code', 'equals', '200']
- ['inconclusive', 'status-code', 'equals', '403']
- ['inconclusive', 'status-code', 'equals', '404']
- ['failure']
----
Tested:
Server setup | Test result
--------------------------------------------------
access denied | inconclusive (it might be allowed to other files)
always fatal | failure
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\InnocentRequestTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class InnocentRequestTesterTest extends BasisTestCase
{
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new InnocentRequestTester());
$this->assertInconclusive($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$testResult = $fakeServer->runTester(new InnocentRequestTester());
$this->assertSuccess($testResult);
}
}

View File

@@ -0,0 +1,257 @@
<?php
/*
subdir: module-loaded
subtests:
- subdir: server-signature
requirements: htaccessEnabled()
files:
- filename: '.htaccess'
content: |
ServerSignature Off
<IfModule mod_xxx.c>
ServerSignature On
</IfModule>
- filename: 'test.php'
content: |
<?php
if (isset($_SERVER['SERVER_SIGNATURE']) && ($_SERVER['SERVER_SIGNATURE'] != '')) {
echo 1;
} else {
echo 0;
}
interpretation:
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- subdir: rewrite
...
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
access denied | inconclusive (it might be allowed to other files)
it works | success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\ModuleLoadedTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class ModuleLoadedTesterTest extends BasisTestCase
{
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertFailure($testResult);
}
public function testInconclusiveWhenAllCrashes()
{
$fakeServer = new FakeServer();
$fakeServer->makeAllCrash();
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertInconclusive($testResult);
}
public function testServerSignatureSucceedsModuleLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/server-signature/on/test.php' => new HttpResponse('1', '200', []),
'/server-signature/off/test.php' => new HttpResponse('1', '200', []),
'/module-loaded/setenvif/server-signature/test.php' => new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertSuccess($testResult);
}
public function testServerSignatureSucceedsModuleNotLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/server-signature/on/test.php' => new HttpResponse('1', '200', []),
'/server-signature/off/test.php' => new HttpResponse('1', '200', []),
'/module-loaded/setenvif/server-signature/test.php' => new HttpResponse('0', '200', [])
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertFailure($testResult);
}
public function testContentDigestWorksModuleLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/content-digest/on/request-me.txt' => new HttpResponse(
'hi',
'200',
['Content-MD5' => 'aaoeu']
),
'/content-digest/off/request-me.txt' => new HttpResponse('hi', '200', []),
'/module-loaded/setenvif/content-digest/request-me.txt' => new HttpResponse(
'',
'200',
['Content-MD5' => 'aoeu']
)
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertSuccess($testResult);
}
public function testContentDigestWorksModuleNotLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/content-digest/on/request-me.txt' => new HttpResponse(
'hi',
'200',
['Content-MD5' => 'aaoeu']
),
'/content-digest/off/request-me.txt' => new HttpResponse('hi', '200', []),
'/module-loaded/setenvif/content-digest/request-me.txt' => new HttpResponse('', '200', [])
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertFailure($testResult);
}
public function testAddTypeWorksModuleLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/add-type/request-me.test' => new HttpResponse(
'hi',
'200',
['Content-Type' => 'image/gif']
),
'/module-loaded/setenvif/add-type/request-me.test' => new HttpResponse(
'hi',
'200',
['Content-Type' => 'image/gif']
)
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertSuccess($testResult);
}
public function testAddTypeWorksModuleNotLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/add-type/request-me.test' => new HttpResponse(
'hi',
'200',
['Content-Type' => 'image/gif']
),
'/module-loaded/setenvif/add-type/request-me.test' => new HttpResponse(
'hi',
'200',
['Content-Type' => 'image/jpeg']
)
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertFailure($testResult);
}
public function testDirectoryIndexWorksModuleLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/directory-index/' => new HttpResponse('1', '200', []),
'/module-loaded/setenvif/directory-index/' => new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertSuccess($testResult);
}
public function testDirectoryIndexWorksModuleNotLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/directory-index/' => new HttpResponse('1', '200', []),
'/module-loaded/setenvif/directory-index/' => new HttpResponse('0', '200', [])
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertFailure($testResult);
}
public function testRewriteWorksModuleLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/rewrite/0.txt' => new HttpResponse('1', '200', []),
'/module-loaded/setenvif/rewrite/request-me.txt' => new HttpResponse('1', '200', []),
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertSuccess($testResult);
}
public function testRewriteWorksModuleNotLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/rewrite/0.txt' => new HttpResponse('1', '200', []),
'/module-loaded/setenvif/rewrite/request-me.txt' => new HttpResponse('0', '200', []),
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertFailure($testResult);
}
public function testHeaderSetWorksModuleLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/header-set/request-me.txt' => new HttpResponse(
'hi',
'200',
['X-Response-Header-Test' => 'test']
),
'/module-loaded/setenvif/header-set/request-me.txt' => new HttpResponse(
'thanks',
'200',
['X-Response-Header-Test' => '1']
),
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertSuccess($testResult);
}
public function testHeaderSetWorksModuleNotLoaded()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/header-set/request-me.txt' => new HttpResponse(
'hi',
'200',
['X-Response-Header-Test' => 'test']
),
'/module-loaded/setenvif/header-set/request-me.txt' => new HttpResponse(
'thanks',
'200',
['X-Response-Header-Test' => '0']
),
]);
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertFailure($testResult);
}
public function testRequestFailure()
{
$fakeServer = new FakeServer();
$fakeServer->failAllRequests();
$testResult = $fakeServer->runTester(new ModuleLoadedTester('setenvif'));
$this->assertInconclusive($testResult);
}
}

View File

@@ -0,0 +1,143 @@
<?php
/*
subdir: pass-info-from-rewrite-to-script-through-env
files:
- filename: '.htaccess'
content: |
<IfModule mod_rewrite.c>
# Testing if we can pass environment variable from .htaccess to script in a RewriteRule
# We pass document root, because that can easily be checked by the script
RewriteEngine On
RewriteRule ^test\.php$ - [E=PASSTHROUGHENV:%{DOCUMENT_ROOT},L]
</IfModule>
- filename: 'test.php'
content: |
<?php
function getEnvPassedInRewriteRule($envName) {
// Environment variables passed through the REWRITE module have "REWRITE_" as a prefix
// (in Apache, not Litespeed, if I recall correctly).
// Multiple iterations causes multiple REWRITE_ prefixes, and we get many environment variables set.
// We simply look for an environment variable that ends with what we are looking for.
// (so make sure to make it unique)
$len = strlen($envName);
foreach ($_SERVER as $key => $item) {
if (substr($key, -$len) == $envName) {
return $item;
}
}
return false;
}
$result = getEnvPassedInRewriteRule('PASSTHROUGHENV');
if ($result === false) {
echo '0';
exit;
}
echo ($result == $_SERVER['DOCUMENT_ROOT'] ? '1' : '0');
request:
url: 'test.php'
interpretation:
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['inconclusive', 'body', 'begins-with', '<?php']
- ['inconclusive']
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
forbidden directives (fatal) | failure
access denied | inconclusive (it might be allowed to other files)
directive has no effect | failure
php is unprocessed | inconclusive
directive works | success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\PassInfoFromRewriteToScriptThroughEnvTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class PassInfoFromRewriteToScriptThroughEnvTesterTest extends BasisTestCase
{
/* can't do this test, it would require processing PHP
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new PassInfoFromRewriteToScriptThroughEnvTester());
$this->assertFailure($testResult);
}*/
public function testDisallowedDirectivesFatal()
{
$fakeServer = new FakeServer();
$fakeServer->disallowAllDirectives('fatal');
$testResult = $fakeServer->runTester(new PassInfoFromRewriteToScriptThroughEnvTester());
$this->assertFailure($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new PassInfoFromRewriteToScriptThroughEnvTester());
$this->assertInconclusive($testResult);
}
/**
* Test when the magic is not working
* This could happen when:
* - Any of the directives are forbidden (non-fatal)
* - Any of the modules are not loaded
* - Perhaps these advanced features are not working on all platforms
* (does LiteSpeed ie support these this?)
*/
public function testMagicNotWorking()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/pass-info-from-rewrite-to-script-through-env/test.php' =>
new HttpResponse('0', '200', [])
]);
$testResult = $fakeServer->runTester(new PassInfoFromRewriteToScriptThroughEnvTester());
$this->assertFailure($testResult);
}
public function testPHPNotProcessed()
{
$fakeServer = new FakeServer();
$fakeServer->handlePHPasText();
$testResult = $fakeServer->runTester(
new PassInfoFromRewriteToScriptThroughEnvTester()
);
$this->assertInconclusive($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/pass-info-from-rewrite-to-script-through-env/test.php' =>
new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(
new PassInfoFromRewriteToScriptThroughEnvTester()
);
$this->assertSuccess($testResult);
}
}

View File

@@ -0,0 +1,129 @@
<?php
/*
subdir: pass-info-from-rewrite-to-script-through-request-header
files:
- filename: '.htaccess'
content: |
<IfModule mod_rewrite.c>
RewriteEngine On
# Testing if we can pass an environment variable through a request header
# We pass document root, because that can easily be checked by the script
<IfModule mod_headers.c>
RequestHeader set PASSTHROUGHHEADER "%{PASSTHROUGHHEADER}e" env=PASSTHROUGHHEADER
</IfModule>
RewriteRule ^test\.php$ - [E=PASSTHROUGHHEADER:%{DOCUMENT_ROOT},L]
</IfModule>
- filename: 'test.php'
content: |
<?php
if (isset($_SERVER['HTTP_PASSTHROUGHHEADER'])) {
echo ($_SERVER['HTTP_PASSTHROUGHHEADER'] == $_SERVER['DOCUMENT_ROOT'] ? 1 : 0);
exit;
}
echo '0';
request:
url: 'test.php'
interpretation:
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['inconclusive', 'body', 'begins-with', '<?php']
- ['inconclusive']
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
forbidden directives (fatal) | failure
access denied | inconclusive (it might be allowed to other files)
directive has no effect | failure
php is unprocessed | inconclusive
directive works | success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\PassInfoFromRewriteToScriptThroughRequestHeaderTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class PassInfoFromRewriteToScriptThroughRequestHeaderTesterTest extends BasisTestCase
{
/* can't do this test, it would require processing PHP
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new PassInfoFromRewriteToScriptThroughRequestHeaderTester());
$this->assertFailure($testResult);
}*/
public function testDisallowedDirectivesFatal()
{
$fakeServer = new FakeServer();
$fakeServer->disallowAllDirectives('fatal');
$testResult = $fakeServer->runTester(new PassInfoFromRewriteToScriptThroughRequestHeaderTester());
$this->assertFailure($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new PassInfoFromRewriteToScriptThroughRequestHeaderTester());
$this->assertInconclusive($testResult);
}
/**
* Test when the magic is not working
* This could happen when:
* - Any of the directives are forbidden (non-fatal)
* - Any of the modules are not loaded
* - Perhaps these advanced features are not working on all platforms
* (does LiteSpeed ie support these this?)
*/
public function testMagicNotWorking()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/pass-info-from-rewrite-to-script-through-request-header/test.php' =>
new HttpResponse('0', '200', [])
]);
$testResult = $fakeServer->runTester(new PassInfoFromRewriteToScriptThroughRequestHeaderTester());
$this->assertFailure($testResult);
}
public function testPHPNotProcessed()
{
$fakeServer = new FakeServer();
$fakeServer->handlePHPasText();
$testResult = $fakeServer->runTester(
new PassInfoFromRewriteToScriptThroughRequestHeaderTester()
);
$this->assertInconclusive($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/pass-info-from-rewrite-to-script-through-request-header/test.php' =>
new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(
new PassInfoFromRewriteToScriptThroughRequestHeaderTester()
);
$this->assertSuccess($testResult);
}
}

View File

@@ -0,0 +1,123 @@
<?php
/*
subdir: request-header
files:
- filename: '.htaccess'
content: |
<IfModule mod_headers.c>
# Certain hosts seem to strip non-standard request headers,
# so we use a standard one to avoid a false negative
RequestHeader set User-Agent "request-header-test"
</IfModule>
- filename: 'test.php'
content: |
<?php
if (isset($_SERVER['HTTP_USER_AGENT'])) {
echo $_SERVER['HTTP_USER_AGENT'] == 'request-header-test' ? 1 : 0;
} else {
echo 0;
}
request:
url: 'test.php'
interpretation:
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['inconclusive', 'body', 'begins-with', '<?php'],
TODO:
TEST: php_flag engine off
https://stackoverflow.com/questions/1271899/disable-php-in-directory-including-all-sub-directories-with-htaccess
TEST: RemoveHandler and RemoveType (https://electrictoolbox.com/disable-php-apache-htaccess/)
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
forbidden directives (fatal) | failure
access denied | inconclusive (it might be allowed to other files)
directive has no effect | failure
php is unprocessed | inconclusive
directive works | success
TODO:
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\RequestHeaderTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class RequestHeaderTesterTest extends BasisTestCase
{
/* can't do this test, it would require processing PHP
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new RequestHeaderTester());
$this->assertFailure($testResult);
}*/
public function testDisallowedDirectivesFatal()
{
$fakeServer = new FakeServer();
$fakeServer->disallowAllDirectives('fatal');
$testResult = $fakeServer->runTester(new RequestHeaderTester());
$this->assertFailure($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new RequestHeaderTester());
$this->assertInconclusive($testResult);
}
/**
* Test when the directive has no effect.
* This could happen when:
* - The directive is forbidden (non-fatal)
* - The module is not loaded
*/
public function testDirectiveHasNoEffect()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/request-header/test.php' => new HttpResponse('0', '200', [])
]);
$testResult = $fakeServer->runTester(new RequestHeaderTester());
$this->assertFailure($testResult);
}
public function testPHPNotProcessed()
{
$fakeServer = new FakeServer();
$fakeServer->handlePHPasText();
$testResult = $fakeServer->runTester(new RequestHeaderTester());
$this->assertInconclusive($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/request-header/test.php' => new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(new RequestHeaderTester());
$this->assertSuccess($testResult);
}
}

View File

@@ -0,0 +1,98 @@
<?php
/*
subdir: rewrite
files:
- filename: '.htaccess'
content: |
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^0\.txt$ 1\.txt [L]
</IfModule>
- filename: '0.txt'
content: '0'
- filename: '1.txt'
content: '1'
request:
url: '0.txt'
interpretation:
- [success, body, equals, '1']
- [failure, body, equals, '0']
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
forbidden directives (fatal) | failure
access denied | inconclusive (it might be allowed to other files)
directive has no effect | failure
| success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\RewriteTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class RewriteTesterTest extends BasisTestCase
{
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new RewriteTester());
$this->assertFailure($testResult);
}
public function testDisallowedDirectivesFatal()
{
$fakeServer = new FakeServer();
$fakeServer->disallowAllDirectives('fatal');
$testResult = $fakeServer->runTester(new RewriteTester());
$this->assertFailure($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new RewriteTester());
$this->assertInconclusive($testResult);
}
/**
* Test when the directive has no effect.
* This could happen when:
* - The directive is forbidden (non-fatal)
* - The module is not loaded
*/
public function testDirectiveHasNoEffect()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/rewrite/0.txt' => new HttpResponse('0', '200', [])
]);
$testResult = $fakeServer->runTester(new RewriteTester());
$this->assertFailure($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/rewrite/0.txt' => new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(new RewriteTester());
$this->assertSuccess($testResult);
}
}

View File

@@ -0,0 +1,148 @@
<?php
/*
subdir: server-signature
subtests:
- subdir: on
files:
- filename: '.htaccess'
content: |
ServerSignature On
- filename: 'test.php'
content: |
<?php
if (isset($_SERVER['SERVER_SIGNATURE']) && ($_SERVER['SERVER_SIGNATURE'] != '')) {
echo 1;
} else {
echo 0;
}
request:
url: 'test.php'
interpretation:
- ['inconclusive', 'body', 'isEmpty']
- ['inconclusive', 'status-code', 'not-equals', '200']
- ['failure', 'body', 'equals', '0']
- subdir: off
files:
- filename: '.htaccess'
content: |
ServerSignature Off
- filename: 'test.php'
content: |
<?php
if (isset($_SERVER['SERVER_SIGNATURE']) && ($_SERVER['SERVER_SIGNATURE'] != '')) {
echo 0;
} else {
echo 1;
}
request:
url: 'test.php'
interpretation:
- ['inconclusive', 'body', 'isEmpty']
- ['success', 'body', 'equals', '1']
- ['failure', 'body', 'equals', '0']
- ['inconclusive']
----
Tested:
Server setup | Test result
--------------------------------------------------
.htaccess disabled | failure
forbidden directives (fatal) | inconclusive (special!)
access denied | inconclusive (it might be allowed to other files)
directive has no effect | failure
| success
*/
namespace HtaccessCapabilityTester\Tests\Testers;
use HtaccessCapabilityTester\HttpResponse;
use HtaccessCapabilityTester\Testers\ServerSignatureTester;
use HtaccessCapabilityTester\Tests\FakeServer;
use PHPUnit\Framework\TestCase;
class ServerSignatureTesterTest extends BasisTestCase
{
/*
can't do this test as our fake server does not execute PHP
public function testHtaccessDisabled()
{
$fakeServer = new FakeServer();
$fakeServer->disableHtaccess();
$testResult = $fakeServer->runTester(new ServerSignatureTester());
$this->assertFailure($testResult);
}*/
public function testDisallowedDirectivesFatal()
{
$fakeServer = new FakeServer();
$fakeServer->disallowAllDirectives('fatal');
$testResult = $fakeServer->runTester(new ServerSignatureTester());
$this->assertFailure($testResult);
// SPECIAL!
// As ServerSignature is in core and AllowOverride is None, the tester assumes
// that this does not happen. The 500 must then be another problem, which is why
// it returns inconclusive
//$this->assertInconclusive($testResult);
}
public function testAccessAllDenied()
{
$fakeServer = new FakeServer();
$fakeServer->denyAllAccess();
$testResult = $fakeServer->runTester(new ServerSignatureTester());
$this->assertInconclusive($testResult);
}
/**
* Test when the directive has no effect.
* This could happen when:
* - The directive is forbidden (non-fatal)
* - The module is not loaded
*
* This tests when ServerSignature is set, and the directive has no effect.
*/
public function testDirectiveHasNoEffect1()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/server-signature/on/test.php' => new HttpResponse('1', '200', []),
'/server-signature/off/test.php' => new HttpResponse('0', '200', [])
]);
$testResult = $fakeServer->runTester(new ServerSignatureTester());
$this->assertFailure($testResult);
}
/**
* This tests when ServerSignature is unset, and the directive has no effect.
*/
public function testDirectiveHasNoEffect2()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/server-signature/on/test.php' => new HttpResponse('0', '200', []),
'/server-signature/off/test.php' => new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(new ServerSignatureTester());
$this->assertFailure($testResult);
}
public function testSuccess()
{
$fakeServer = new FakeServer();
$fakeServer->setResponses([
'/server-signature/on/test.php' => new HttpResponse('1', '200', []),
'/server-signature/off/test.php' => new HttpResponse('1', '200', [])
]);
$testResult = $fakeServer->runTester(new ServerSignatureTester());
$this->assertSuccess($testResult);
}
}

View File

@@ -0,0 +1,76 @@
# PHP CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-php/ for more details
#
version: 2
jobs:
build71:
docker:
- image: circleci/php:7.1
steps:
- checkout
- run: sudo apt update
- run: sudo docker-php-ext-install zip
- restore_cache:
keys:
# "composer.lock" can be used if it is committed to the repo
- v1-dependencies-{{ checksum "composer.json" }}
# fallback to using the latest cache if no exact match is found
- v1-dependencies-
- run: composer install -n --prefer-dist
- save_cache:
key: v1-dependencies-{{ checksum "composer.json" }}
paths:
- ./vendor
- run: ./vendor/bin/phpunit
- run: wget https://scrutinizer-ci.com/ocular.phar
- run: php ocular.phar code-coverage:upload --format=php-clover coverage.clover
build70:
docker:
- image: circleci/php:7.0
steps:
- checkout
- run: sudo apt update
- restore_cache:
keys:
- v0-dependencies-{{ checksum "composer.json" }}
- v0-dependencies-
- run: composer install -n --prefer-dist
- save_cache:
key: v0-dependencies-{{ checksum "composer.json" }}
paths:
- ./vendor
- run: composer test
build56:
docker:
- image: circleci/php:5.6
steps:
- checkout
- run: sudo apt update
- restore_cache:
keys:
- v56-dependencies-{{ checksum "composer.json" }}
- v56-dependencies-
- run: composer install -n --prefer-dist
- save_cache:
key: v56-dependencies-{{ checksum "composer.json" }}
paths:
- ./vendor
- run: composer test
workflows:
version: 2
build_and_test_all:
jobs:
#- build71
#- build70
#- build56

View File

@@ -0,0 +1,19 @@
<?php
$finder = PhpCsFixer\Finder::create()
->exclude('tests')
->in(__DIR__)
;
$config = PhpCsFixer\Config::create();
$config
->setRules([
'@PSR2' => true,
'array_syntax' => [
'syntax' => 'short',
],
])
->setFinder($finder)
;
return $config;

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2018 Bjørn Rosell
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,110 @@
# image-mime-type-guesser
[![Latest Stable Version](https://img.shields.io/packagist/v/rosell-dk/image-mime-type-guesser.svg?style=flat-square)](https://packagist.org/packages/rosell-dk/image-mime-type-guesser)
[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%205.6-8892BF.svg?style=flat-square)](https://php.net)
[![Build Status](https://img.shields.io/github/actions/workflow/status/rosell-dk/image-mime-type-guesser/ci.yml?branch=master&logo=GitHub&style=flat-square)](https://github.com/rosell-dk/image-mime-type-guesser/actions/workflows/ci.yml)
[![Coverage](https://img.shields.io/endpoint?url=https://little-b.it/image-mime-type-guesser/code-coverage/coverage-badge.json)](http://little-b.it/image-mime-type-guesser/code-coverage/coverage/index.html)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://github.com/rosell-dk/image-mime-type-guesser/blob/master/LICENSE)
[![Monthly Downloads](http://poser.pugx.org/rosell-dk/image-mime-type-guesser/d/monthly)](https://packagist.org/packages/rosell-dk/image-mime-type-guesser)
[![Dependents](http://poser.pugx.org/rosell-dk/image-mime-type-guesser/dependents)](https://packagist.org/packages/rosell-dk/image-mime-type-guesser/dependents?order_by=downloads)
*Detect / guess mime type of an image*
Do you need to determine if a file is an image?<br>
And perhaps you also want to know the mime type of the image?<br>
&ndash; You come to the right library.
Ok, actually the library cannot offer mime type detection for images which works *on all platforms*, but it can try a whole stack of methods and optionally fall back to guess from the file extension.
The stack of detect methods are currently (and in that order):
- [This signature sniffer](https://github.com/rosell-dk/image-mime-type-sniffer) *Does not require any extensions. Recognizes popular image formats only*
- [`finfo`](https://www.php.net/manual/en/class.finfo.php) *Requires fileinfo extension to be enabled. (PHP 5 >= 5.3.0, PHP 7, PHP 8, PECL fileinfo >= 0.1.0)*
- [`exif_imagetype`](https://www.php.net/manual/en/function.exif-imagetype.php) *Requires that PHP is compiled with exif (PHP 4 >= 4.3.0, PHP 5, PHP 7, PHP 8)*
- [`mime_content_type`](https://www.php.net/manual/en/function.mime-content-type.php) *Requires fileinfo. (PHP 4 >= 4.3.0, PHP 5, PHP 7, PHP 8)*
Note that all these methods except the signature sniffer relies on the mime type mapping on the server (the `mime.types` file in Apache). If the server doesn't know about a certain mime type, it will not be detected. This does however not mean that the methods relies on the file extension. A png file renamed to "png.jpeg" will be correctly identified as *image/png*.
Besides the detection methods, the library also comes with a method for mapping file extension to mime type. It is rather limited, though.
## Installation
Install with composer:
```
composer require rosell-dk/image-mime-type-guesser
```
## Usage
To detect the mime type of a file, use `ImageMimeTypeGuesser::detect($filePath)`. It returns the mime-type, if the file is recognized as an image. *false* is returned if it is not recognized as an image. *null* is returned if the mime type could not be determined (ie due to none of the methods being available).
Example:
```php
use ImageMimeTypeGuesser\ImageMimeTypeGuesser;
$result = ImageMimeTypeGuesser::detect($filePath);
if (is_null($result)) {
// the mime type could not be determined
} elseif ($result === false) {
// it is NOT an image (not a mime type that the server knows about anyway)
// This happens when:
// a) The mime type is identified as something that is not an image (ie text)
// b) The mime type isn't identified (ie if the image type is not known by the server)
} else {
// it is an image, and we know its mime type!
$mimeType = $result;
}
```
For convenience, you can use *detectIsIn* method to test if a detection is in a list of mimetypes.
```php
if (ImageMimeTypeGuesser::detectIsIn($filePath, ['image/jpeg','image/png'])) {
// The file is a jpeg or a png
}
```
The `detect` method does not resort to mapping from file extension. In most cases you do not want to do that. In some cases it can be insecure to do that. For example, if you want to prevent a user from uploading executable files, you probably do not want to allow her to upload executable files with innocent looking file extenions, such as "evil-exe.jpg".
In some cases, though, you simply want a best guess, and in that case, falling back to mapping from file extension makes sense. In that case, you can use the *guess* method instead of the *detect* method. Or you can use *lenientGuess*. Lenient guess is even more slacky and will turn to mapping not only when dectect return *null*, but even when it returns *false*.
*Warning*: Beware that guessing from file extension is unsuited when your aim is to protect the server from harmful uploads.
*Notice*: Only a limited set of image extensions is recognized by the extension to mimetype mapper - namely the following: { apng, avif, bmp, gif, ico, jpg, jpeg, png, tif, tiff, webp, svg }. If you need some other specifically, feel free to add a PR, or ask me to do it by creating an issue.
Example:
```php
$result = ImageMimeTypeGuesser::guess($filePath);
if ($result !== false) {
// It appears to be an image
// BEWARE: This is only a guess, as we resort to mapping from file extension,
// when the file cannot be properly detected.
// DO NOT USE THIS GUESS FOR PROTECTING YOUR SERVER
$mimeType = $result;
} else {
// It does not appear to be an image
}
```
The guess functions also have convenience methods for testing against a list of mime types. They are called `ImageMimeTypeGuesser::guessIsIn` and `ImageMimeTypeGuesser::lenientGuessIsIn`.
Example:
```php
if (ImageMimeTypeGuesser::guessIsIn($filePath, ['image/jpeg','image/png'])) {
// The file appears to be a jpeg or a png
}
```
## Alternatives
Other sniffers:
- https://github.com/Intervention/mimesniffer
- https://github.com/zjsxwc/mime-type-sniffer
- https://github.com/Tinram/File-Identifier
## Do you like what I do?
Perhaps you want to support my work, so I can continue doing it :)
- [Become a backer or sponsor on Patreon](https://www.patreon.com/rosell).
- [Buy me a Coffee](https://ko-fi.com/rosell)

View File

@@ -0,0 +1,63 @@
{
"name": "rosell-dk/image-mime-type-guesser",
"description": "Guess mime type of images",
"type": "library",
"license": "MIT",
"keywords": ["mime", "mime type", "image", "images"],
"scripts": {
"ci": [
"@test",
"@phpcs-all",
"@composer validate --no-check-all --strict",
"@phpstan"
],
"cs-fix-all": [
"php-cs-fixer fix src"
],
"cs-fix": "php-cs-fixer fix",
"cs-dry": "php-cs-fixer fix --dry-run --diff",
"test": "phpunit --coverage-text=build/coverage.txt --coverage-clover=build/coverage.clover --coverage-html=build/coverage --whitelist=src tests",
"test-no-cov": "phpunit tests",
"test2": "phpunit tests",
"phpcs": "phpcs --standard=phpcs-ruleset.xml",
"phpcs-all": "phpcs --standard=phpcs-ruleset.xml src",
"phpcbf": "phpcbf --standard=PSR2",
"phpstan": "vendor/bin/phpstan analyse src --level=4"
},
"extra": {
"scripts-descriptions": {
"ci": "Run tests before CI",
"phpcs": "Checks coding styles (PSR2) of file/dir, which you must supply. To check all, supply 'src'",
"phpcbf": "Fix coding styles (PSR2) of file/dir, which you must supply. To fix all, supply 'src'",
"cs-fix-all": "Fix the coding style of all the source files, to comply with the PSR-2 coding standard",
"cs-fix": "Fix the coding style of a PHP file or directory, which you must specify.",
"test": "Launches the preconfigured PHPUnit"
}
},
"autoload": {
"psr-4": { "ImageMimeTypeGuesser\\": "src/" }
},
"autoload-dev": {
"psr-4": { "ImageMimeTypeGuesser\\Tests\\": "tests/" }
},
"authors": [
{
"name": "Bjørn Rosell",
"homepage": "https://www.bitwise-it.dk/contact",
"role": "Project Author"
}
],
"require": {
"php": "^5.6 | ^7.0 | ^8.0",
"rosell-dk/image-mime-type-sniffer": "^1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.11",
"phpunit/phpunit": "^9.3",
"squizlabs/php_codesniffer": "3.*",
"phpstan/phpstan": "^1.10"
},
"config": {
"sort-packages": true
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<ruleset name="Custom Standard">
<description>PSR2 without line ending rule - let git manage the EOL cross the platforms</description>
<rule ref="PSR2" />
<rule ref="Generic.Files.LineEndings">
<exclude name="Generic.Files.LineEndings.InvalidEOLChar"/>
</rule>
</ruleset>

View File

@@ -0,0 +1,4 @@
parameters:
reportUnmatchedIgnoredErrors: false
ignoreErrors:
- '#Unsafe usage of new static#'

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
verbose="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name=":vendor Test Suite">
<directory suffix="Test.php">./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">src/</directory>
</whitelist>
</filter>
</phpunit>

View File

@@ -0,0 +1,52 @@
<?php
namespace ImageMimeTypeGuesser\Detectors;
abstract class AbstractDetector
{
/**
* Try to detect mime type of image
*
* Returns:
* - mime type (string) (if it is in fact an image, and type could be determined)
* - false (if it is not an image type that the server knowns about)
* - null (if nothing can be determined)
*
* @param string $filePath The path to the file
* @return string|false|null mimetype (if it is an image, and type could be determined),
* false (if it is not an image type that the server knowns about)
* or null (if nothing can be determined)
*/
abstract protected function doDetect($filePath);
/**
* Create an instance of this class
*
* @return static
*/
public static function createInstance()
{
return new static();
}
/**
* Detect mime type of file (for images only)
*
* Returns:
* - mime type (string) (if it is in fact an image, and type could be determined)
* - false (if it is not an image type that the server knowns about)
* - null (if nothing can be determined)
*
* @param string $filePath The path to the file
* @return string|false|null mimetype (if it is an image, and type could be determined),
* false (if it is not an image type that the server knowns about)
* or null (if nothing can be determined)
*/
public static function detect($filePath)
{
if (!@file_exists($filePath)) {
return false;
}
return self::createInstance()->doDetect($filePath);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace ImageMimeTypeGuesser\Detectors;
use \ImageMimeTypeGuesser\Detectors\AbstractDetector;
class ExifImageType extends AbstractDetector
{
/**
* Try to detect mime type of image using *exif_imagetype*.
*
* Returns:
* - mime type (string) (if it is in fact an image, and type could be determined)
* - false (if it is not an image type that the server knowns about)
* - null (if nothing can be determined)
*
* @param string $filePath The path to the file
* @return string|false|null mimetype (if it is an image, and type could be determined),
* false (if it is not an image type that the server knowns about)
* or null (if nothing can be determined)
*/
protected function doDetect($filePath)
{
// exif_imagetype is fast, however not available on all systems,
// It may return false. In that case we can rely on that the file is not an image (and return false)
if (function_exists('exif_imagetype')) {
try {
$imageType = exif_imagetype($filePath);
return ($imageType ? image_type_to_mime_type($imageType) : false);
} catch (\Exception $e) {
// Might for example get "Read error!"
// (for some reason, this happens on very small files)
// We handle such errors as indeterminable (null)
return null;
// well well, don't let this stop us
//echo $e->getMessage();
//throw($e);
}
}
return null;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace ImageMimeTypeGuesser\Detectors;
class FInfo extends AbstractDetector
{
/**
* Try to detect mime type of image using *finfo* class.
*
* Returns:
* - mime type (string) (if it is in fact an image, and type could be determined)
* - false (if it is not an image type that the server knowns about)
* - null (if nothing can be determined)
*
* @param string $filePath The path to the file
* @return string|false|null mimetype (if it is an image, and type could be determined),
* false (if it is not an image type that the server knowns about)
* or null (if nothing can be determined)
*/
protected function doDetect($filePath)
{
if (class_exists('finfo')) {
// phpcs:ignore PHPCompatibility.PHP.NewClasses.finfoFound
$finfo = new \finfo(FILEINFO_MIME);
$result = $finfo->file($filePath);
if ($result === false) {
// false means an error occured
return null;
} else {
$mime = explode('; ', $result);
$result = $mime[0];
if (strpos($result, 'image/') === 0) {
return $result;
} else {
return false;
}
}
}
return null;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace ImageMimeTypeGuesser\Detectors;
class GetImageSize extends AbstractDetector
{
/**
* Try to detect mime type of image using *getimagesize()*.
*
* Returns:
* - mime type (string) (if it is in fact an image, and type could be determined)
* - false (if it is not an image type that the server knowns about)
* - null (if nothing can be determined)
*
* @param string $filePath The path to the file
* @return string|false|null mimetype (if it is an image, and type could be determined),
* false (if it is not an image type that the server knowns about)
* or null (if nothing can be determined)
*/
protected function doDetect($filePath)
{
// getimagesize is slower than exif_imagetype
// It may not return "mime". In that case we can rely on that the file is not an image (and return false)
if (function_exists('getimagesize')) {
try {
$imageSize = getimagesize($filePath);
return (isset($imageSize['mime']) ? $imageSize['mime'] : false);
} catch (\Exception $e) {
// well well, don't let this stop us either
return null;
}
}
return null;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace ImageMimeTypeGuesser\Detectors;
class MimeContentType extends AbstractDetector
{
/**
* Try to detect mime type of image using *mime_content_type()*.
*
* Returns:
* - mime type (string) (if it is in fact an image, and type could be determined)
* - false (if it is not an image type that the server knowns about)
* - null (if nothing can be determined)
*
* @param string $filePath The path to the file
* @return string|false|null mimetype (if it is an image, and type could be determined),
* false (if it is not an image type that the server knowns about)
* or null (if nothing can be determined)
*/
protected function doDetect($filePath)
{
// mime_content_type supposedly used to be deprecated, but it seems it isn't anymore
// it may return false on failure.
if (function_exists('mime_content_type')) {
try {
$result = mime_content_type($filePath);
if ($result !== false) {
if (strpos($result, 'image/') === 0) {
return $result;
} else {
return false;
}
}
} catch (\Exception $e) {
// we are unstoppable!
// TODO:
// We should probably throw... - we will do in version 1.0.0
//throw $e;
}
}
return null;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace ImageMimeTypeGuesser\Detectors;
use \ImageMimeTypeGuesser\Detectors\AbstractDetector;
use \ImageMimeTypeSniffer\ImageMimeTypeSniffer;
class SignatureSniffer extends AbstractDetector
{
/**
* Try to detect mime type by sniffing the first four bytes.
*
* Returns:
* - mime type (string) (if it is in fact an image, and type could be determined)
* - false (if it is not an image type that the server knowns about)
* - null (if nothing can be determined)
*
* @param string $filePath The path to the file
* @return string|false|null mimetype (if it is an image, and type could be determined),
* false (if it is not an image type that the server knowns about)
* or null (if nothing can be determined)
*/
protected function doDetect($filePath)
{
return ImageMimeTypeSniffer::detect($filePath);
}
}

Some files were not shown because too many files have changed in this diff Show More