steadlane/silverstripe-cloudflare

View on GitHub
src/Purge/Purge.php

Summary

Maintainability
D
1 day
Test Coverage
<?php

namespace SteadLane\Cloudflare;

use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Control\Director;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\ORM\DataObject;
use SteadLane\Cloudflare\Messages\Notifications;

/**
 * Class Purge
 * @package SteadLane\Cloudflare
 */
class Purge
{
    use Injectable;
    use Extensible;

    /**
     * @var string
     */
    protected $successMessage;

    /**
     * @var string
     */
    protected $failureMessage;

    /**
     * @var bool
     */
    protected $testOnly = false;

    /**
     * @var string
     */
    protected $testResultSuccess;

    /**
     * @var array
     */
    protected $files;

    /**
     * @var bool
     */
    protected $purgeEverything = false;

    /**
     * @var
     */
    protected $response;

    /**
     * @var array
     */
    protected $fileTypes = array(
        'image' => array(
            "bmp" ,"gif" ,"jpg" ,"jpeg" ,"pcx" ,"tif" ,"png" ,"alpha","als" ,"cel" ,"icon" ,"ico" ,"ps", "svg"
        ),
        'javascript' => array(
            "js"
        ),
        'css' => array(
            'css', 'css.map'
        )
    );

    /**
     * @var string
     */
    protected static $endpoint = "https://api.cloudflare.com/client/v4/zones/:identifier/purge_cache";

    /**
     * @param array $files
     * @return $this
     */
    public function setFiles(array $files)
    {
        $this->clearFiles();
        $this->pushFile($files);

        return $this;
    }

    /**
     * @param string|array $file
     * @return $this
     */
    public function pushFile($file)
    {
        if (!is_array($this->files)) {
            $this->files = array();
        }

        if (is_array($file)) {
            foreach ($file as $pointer) {
                $this->pushFile($pointer);
            }

            return $this;
        }

        array_push($this->files, $this->convertToAbsolute($file));

        return $this;
    }

    /**
     * Recursively find files with a specific extension(s) starting at the document root
     *
     * @param string|array $extensions
     * @param null|string $dir A directory relevant to the project root, if null the entire project root will be searched
     * @return $this
     */
    public function findFilesWithExts($extensions, $dir = null)
    {
        $files = array();
        $rootDir = rtrim(str_replace('//', '/', $_SERVER['DOCUMENT_ROOT'] . Director::baseURL() . "/" . $dir), '/');

        if (is_array($extensions)) {
            foreach($extensions as &$ext) {
                $ext = ltrim($ext, '.');
            }
            $extensions = implode("|", $extensions);
        }

        $extensions = ltrim($extensions, '.');
        $pattern = sprintf('/.(%s)$/i', $extensions);

        if (is_string($extensions)) {
            $files = $this->fileSearch($rootDir, $pattern);
        }

        $this->pushFile($files);

        return $this;
    }

    /**
     * Recursive glob-like function
     *
     * @param string $dir
     * @param string $pattern Fully qualified regex pattern
     * @return array|bool
     */
    public function fileSearch($dir, $pattern)
    {
        if (!is_dir($dir)) {
            return false;
        }

        $files = array();
        $this->fileSearchAux($dir, $pattern, $files);
        return $files;
    }

    /**
     * Auxiliary function to avoid writing temporary temporary lists on the way back
     *
     * @param string $dir
     * @param string $pattern
     * @param array $files
     */
    private function fileSearchAux($dir, $pattern, &$files) {
        $handle = opendir($dir);
        if ($handle) {
            while (($file = readdir($handle)) !== false) {

                if ($file == '.' || $file == '..') {
                    continue;
                }

                $filePath = $dir == '.' ? $file : $dir . '/' . $file;

                if (is_link($filePath)) {
                    continue;
                }

                if (is_file($filePath)) {
                    if (preg_match($pattern, $filePath)) {
                        $files[] = $filePath;
                    }
                }

                if (is_dir($filePath) && !$this->isBlacklisted($file)) {
                    $this->fileSearchAux($filePath, $pattern, $files);
                }
            }
            closedir($handle);
        }
    }


    /**
     * Converts /public_html/path/to/file.ext to example.com/path/to/file.ext, it is perfectly safe to hand this
     * an "already absolute" url.
     *
     * @param string|array $files
     * @return string|array|bool Dependent on input, returns false if input is neither an array, or a string.
     */
    public function convertToAbsolute($files)
    {
        // It's not the best feeling to have to add http:// here, despite it's SSL variant being picked up
        // by getUrlVariants(). However without it cloudflare will respond with an error similar to:
        // "You may only purge files for this zone only"
        // @TODO: get rid of this stupidity, surely we don't need to use DOCUMENT_ROOT at all?
        $baseUrl = "http://" . CloudFlare::singleton()->getServerName() . "/";
        $rootDir = str_replace("//", "/", $_SERVER['DOCUMENT_ROOT']);

        if (is_array($files)) {
            foreach ($files as $index => $file) {
                $basename = basename($file);
                $basenameEncoded = urlencode($basename);
                $file = str_replace($basename, $basenameEncoded, $file);

                $files[$index] = str_replace($rootDir, $baseUrl, $file);
                $files[$index] = str_replace($baseUrl.'/', $baseUrl, $files[$index]); // stooopid
                $files[$index] = str_replace($baseUrl.'\\/', $baseUrl, $files[$index]); // stoooopid
            }

            return $files;
        }

        if (is_string($files)) {
            $basename = basename($files);
            $basenameEncoded = urlencode($basename);
            $files = str_replace($basename, $basenameEncoded, $files);

            $files=str_replace($rootDir, $baseUrl, $files);
            $files=str_replace($baseUrl.'/', $baseUrl, $files); // stoooooopid
            return str_replace($baseUrl.'\\/', $baseUrl, $files); // stooooooooooopid
        }

        return false;
    }

    /**
     * @return int
     */
    public function count()
    {
        return (is_array($this->files)) ? count($this->files) : 0;
    }

    /**
     * @return $this
     */
    public function purge()
    {
        $files = $this->getFiles();

        $this->extend("updateFilesBeforePurge", $files);

        if ($this->purgeEverything) {
            $data = array(
                "purge_everything" => true
            );
        } else {
            $data = array(
                "files" => $files
            );
        }

        CloudFlare::debug("Purge::purge() / data = ", $data);
        $this->setResponse($this->handleRequest($data));

        $success = $this->isSuccessful();

        Notifications::handleMessage(
            ($success) ? ($this->getSuccessMessage() ?: false) : ($this->getFailureMessage() ?: false),
            array(
                'file_count' => $this->count()
            )
        );

        return $this;
    }

    /**
     * @return null|array
     */
    public function getFiles()
    {
        return $this->files;
    }

    /**
     * @param bool $bool    If true, no request to CloudFlare will actually be made and instead you will receive a mock
     *                      response
     * @param bool $success True to simulate a successful request, or false to simulate a failure
     *
     * @return $this
     */
    public function setTestOnly($bool, $success) {
        $this->testOnly = $bool;
        $this->testResultSuccess = $success;

        return $this;
    }

    /**
     * @param $response
     *
     * @return $this
     */
    public function setResponse($response)
    {
        $this->extend("onBeforeSetResponse", $response);

        $this->response = $response;

        return $this;
    }

    /**
     * Handles requests for cache purging
     *
     * @param array|null $data
     * @param string $method
     *
     * @param bool $isRecursing
     * @return string|array
     */
    public function handleRequest(array $data = null, $isRecursing = null, $method = 'DELETE')
    {
        if (array_key_exists('files', $data) && !$isRecursing) {
            // get URL variants
            $data['files'] = $this->getUrlVariants($data['files']);
        }

        if (array_key_exists('files', $data) && count($data['files']) > 500) {
            // slice the array into chunks of 500 then recursively call this function.
            // cloudflare limits cache purging to 500 files per request.
            $chunks = ceil(count($data['files']) / 500);
            $start = 0;
            $responses = array();

            for ($i = 0; $i < $chunks; $i++) {
                $chunk = array_slice($data['files'], $start, 500);
                $result = $this->handleRequest(array('files' => $chunk), true);
                $responses[] = json_decode($result, true);
                $start += 500;
            }

            return $responses;
        }

        if ($this->testOnly) {
            return CloudFlare::getMockResponse('Purge', $this->testResultSuccess);
        }

        return CloudFlare::singleton()->curlRequest($this->getEndpoint(), $data, $method);
    }

    /**
     * Generates URL variants (Stage urls, HTTPS, Non-HTTPS)
     *
     * @param $urls
     *
     * @return array
     */
    public function getUrlVariants($urls)
    {
        $output = array();

        foreach ($urls as $url) {
            $output[] = $url;

            // HTTPS Equiv
            if (strstr($url, "http://") && !in_array(str_replace("http://", "https://", $url), $output)) {
                $output[] = str_replace("http://", "https://", $url);
            }

            // HTTP Equiv
            if (strstr($url, "https://") && !in_array(str_replace("https://", "http://", $url), $output)) {
                $output[] = str_replace("http://", "https://", $url);
            }
        }

        $this->extend("onAfterGetUrlVariants", $output);

        return $output;
    }

    /**
     * @return string
     */
    public function getEndpoint()
    {
        $zoneId = CloudFlare::singleton()->fetchZoneID();
        return str_replace(":identifier", $zoneId, static::$endpoint);
    }

    /**
     * @return bool
     */
    public function isSuccessful()
    {
        $response = $this->getResponse();

        if (!is_array($response)) {
            return false;
        }

        if (array_key_exists("0", $response)) {
            // multiple responses in payload, all of them need to be successful otherwise return false;
            foreach ($response as $singular) {
                if ($singular['success']) {
                    continue;
                }

                return false;
            }

            return true;
        }

        if (array_key_exists('success', $response) && $response['success']) {
            return true;
        }

        return false;

    }

    /**
     * @return array
     */
    public function getResponse()
    {
        $response = $this->response;
        if (is_string($response)) {
            $response = json_decode($response, true);
        }

        return $response;
    }

    /**
     * @param bool $bool
     * @return $this
     */
    public function setPurgeEverything($bool = null)
    {
        $this->purgeEverything = ($bool);
        return $this;
    }


    /**
     * @param string $failureMessage
     * @return $this
     */
    public function setFailureMessage($failureMessage)
    {
        $this->failureMessage = $failureMessage;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getFailureMessage()
    {
        return $this->failureMessage;
    }

    /**
     * @param string $successMessage
     * @return $this
     */
    public function setSuccessMessage($successMessage)
    {
        $this->successMessage = $successMessage;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getSuccessMessage()
    {
        return $this->successMessage;
    }

    /**
     * Should we purge related Pages as well as the Page/file/URL that is requested?
     *
     * @return bool
     */
    public function getShouldPurgeRelations()
    {
        return (bool) CloudFlare::config()->should_purge_relations;
    }

    /**
     * Resets the instance
     *
     * @return $this
     */
    public function reset()
    {
        $this->clearFiles();

        $this->response = null;
        $this->successMessage = null;
        $this->failureMessage = null;
        $this->purgeEverything = false;

        return $this;
    }

    /**
     * Clears files
     *
     * @return $this
     */
    public function clearFiles()
    {
        $this->files = null;

        return $this;
    }

    /**
     * @return array
     */
    public function getFileTypes() {

        $types = $this->fileTypes;
        $this->extend('updateCloudFlarePurgeFileTypes', $types);

        return $types;
    }

    /**
     * Checks to see if a certain directory is blacklisted from the fileSearch functionality
     *
     * @param $dir
     *
     * @return bool
     */
    public function isBlacklisted($dir) {
        if (!is_array($blacklist = CloudFlare::config()->purge_dir_blacklist)) {
            return false;
        }

        if (in_array($dir, $blacklist)) {
            return true;
        }

        return false;
    }

    /**
     * Allows you to quickly purge cache for particular files defined in $fileTypes (See ::getFileTypes() for an
     * extension point to update file types)
     *
     * @param string $what E.g 'image', 'javascript', 'css', or user defined
     *
     * @param null   $other_id Allows you to provide a Page ID for example
     *
     * @return bool
     */
    public function quick($what, $other_id = null)
    {
        // create a new instance of self so we don't interrupt anything
        $purger = self::create();
        $what = trim(strtolower($what));

        if ($what == 'page' && isset($other_id)) {
            if (!($other_id instanceof SiteTree)) {
                $other_id = DataObject::get_by_id(SiteTree::class, $other_id);
            }
            $page = $other_id;

            $purger
                ->pushFile(str_replace("//","/",$_SERVER['DOCUMENT_ROOT'] . "/" .$page->Link()))
                ->setSuccessMessage('Cache has been purged for: ' . $page->Link())
                ->purge();

            return $purger->isSuccessful();
        }

        if ($what == 'all') {
            $purger->setPurgeEverything(true)->purge();
            return $purger->isSuccessful();
        }

        $fileTypes = $this->getFileTypes();

        if (!isset($fileTypes[$what])) {
            user_error("Attempted to purge all {$what} types but it has no file extension list defined. See CloudFlare_Purge::\$fileTypes", E_USER_ERROR);
        }

        $purger->findFilesWithExts($fileTypes[$what]);

        if (!$purger->count()) {
            Notifications::handleMessage(
                _t(
                    "CloudFlare.NoFilesToPurge",
                    "No {what} files were found to purge.",
                    "",
                    array(
                        "what" => $what
                    )
                )
            );
        } else {
            $purger->setSuccessMessage(
                _t(
                    "CloudFlare.SuccessFilesPurged",
                    "Successfully purged {file_count} {what} files from cache.",
                    "",
                    array(
                        "what" => $what
                    )
                )
            );

            $purger->purge();
        }

        return $purger->isSuccessful();
    }
}