Thyrosis's avatar

Custom service provider gives Purify errors in PHPUnit test

Hi guys,

This is a weird one. I'm trying to add some extra allowable HTML elements to Steve Baumans' Purify.

It takes a custom ServiceProvider, seeing as the element (anchor target) isn't 'officially' allowed.

Now, don't get me wrong, it works. Perfectly. When showing database-content, the anchor target attribute is shown.

But when I run my tests, half of them fail with the following error:

ErrorException: Attribute 'target' in element 'a' not supported (for information on implementing this, see the support forums)

So what's happening here? Why does the custom service provider work when the application is run 'normally' but not when it's being tested?

And of course, what can I do to get it to work? I don't like these kind of results...

....EEEEEE.EEEEE.EEEFEFEE..........EEEEEE.F... 46 / 46 (100%)

Thanks in advance!

0 likes
11 replies
Nakov's avatar

I think that the Service provider is being booted but the thing that does not work is that you don't have the target attribute in your allowed HTML attributes list. So open the purify.php config file, and change this:

'HTML.Allowed' => 'h1,h2,h3,h4,h5,h6,b,strong,i,em,a[href|title],ul,ol,li,p[style],br,span,img[width|height|alt|src]',

with this:

'HTML.Allowed' => 'h1,h2,h3,h4,h5,h6,b,strong,i,em,a[href|title|target],ul,ol,li,p[style],br,span,img[width|height|alt|src]',

notice the target within the a tag.

Thyrosis's avatar

Hi @Nakov, thanks for your reply.

As said, accessing the application via the browser yields the correct results. The config is fine:

        'HTML.Allowed' => 'a[href|target|title|rel],b,big,blockquote,br,code,del,div,em,h1,h2,h3,h4,h5,h6,hr,i,img[style|width|height|alt|src],li,ol,p[style],pre,small,span[style],strong,sub,sup,table[style|summary],td[abbr],th[abbr],tr,ul,*[style]',

This is the PurifySetupProvider:

<?php

namespace App\Providers;

use HTMLPurifier_HTMLDefinition;
use Stevebauman\Purify\Facades\Purify;
use Illuminate\Support\ServiceProvider;

class PurifySetupProvider extends ServiceProvider
{
    const DEFINITION_ID = 'tinymce-editor';
    const DEFINITION_REV = 1;

    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        /** @var \HTMLPurifier $purifier */
        $purifier = Purify::getPurifier();

        /** @var \HTMLPurifier_Config $config */
        $config = $purifier->config;

        $config->set('HTML.DefinitionID', static::DEFINITION_ID);
        $config->set('HTML.DefinitionRev', static::DEFINITION_REV);

        if ($def = $config->maybeGetRawHTMLDefinition()) {
            $this->setupDefinitions($def);
        }

        $purifier->config = $config;
    }

    protected function setupDefinitions(HTMLPurifier_HTMLDefinition $def)
    {
        $def->addAttribute('a', 'target', 'Text');
    }
}

Which is then loaded in config/app.php

/*
         * Application Service Providers...
         */
        App\Providers\AppServiceProvider::class,
        App\Providers\AuthServiceProvider::class,
        // App\Providers\BroadcastServiceProvider::class,
        App\Providers\EventServiceProvider::class,
        App\Providers\RouteServiceProvider::class,
        App\Providers\PurifySetupProvider::class,
    ],

As said, it works fine using the browser, just not in the tests. Is there any caching going on maybe? For testing? Is there a PHPUnit cache I need to clear?

Nakov's avatar

@Thyrosis based on your provider, I think that you should add the logic within the boot function instead of register.

Thyrosis's avatar

Thanks @nakov . I tried this as well. Same result: accessing the application via browser works fine and allows the target attribute in anchors.

Test results still fail.

Nakov's avatar

I tried to google your problem, and the only thing that I've found that makes sense and I don't know if you have tried it, is to add the allowed frame target. Change _blank with whatever target you use.

$config->set('Attr.AllowedFrameTargets', array('_blank')); 

Other than that I don't think that the problem is specific to the testing env only. You can try removing the custom code in the provider and you will see that the error will be different. Which means that the provider will be booted, but some of the configs are wrong, I am saying all of this based on your error message.

Thyrosis's avatar

TLDR; Am I right in assuming code in an if-statement will never be executed if the statement itself defaults to null?

Well, the extra $config->set doesn't work either @nakov , but you did give me an idea.

I've been backtracking the error, adding a dd($def) to the setupDefinitions method from the PurifySetupProvider. It didn't get called in the tests. But, it didn't get called in the browser either. That leads me to believe that my statement "it works in browser" is actually incorrect. It doesn't work, it just fails silently and doesn't purify anything any more.

That led me to debug through Steve Baumans code and the Ezyang HTMLPurifier. Running through the methods, I think there might be a bug somewhere.

    if ($def = $config->maybeGetRawHTMLDefinition()) {
        $this->setupDefinitions($def);
    }
    public function maybeGetRawHTMLDefinition()
    {
        return $this->getDefinition('HTML', true, true);
    }
    public function getDefinition($type, $raw = false, $optimized = false)
    {
[...]
            if ($optimized) {
                // This code path only gets run once; once we put
                // something in $definitions (which is guaranteed by the
                // trailing code), we always short-circuit above.
                $def = $cache->get($this);
                if ($def) {
                    // save the full definition for later, but don't
                    // return it yet
                    $this->definitions[$type] = $def;
                    return null;
                }
            }

So, $optimized is true. $def is retrieved from the cache and set to $this->definitions[$type]. Return value: null.

Returning back to the start:

    if (null) {
        $this->setupDefinitions($def);
    }

Am I right in assuming $this->setupDefinitions($def) will never be executed because $def always defaults to null?

Nakov's avatar

This is true if you have cached the wrong configuration, because once cached it defaults to that version, and it will never call the setupDefinitions. So whenever you change something I think that you should clear the cache, here is a note from the library itself:

Note: Remember that after this definition is created, and you have ran Purify::clean(), the definition will be cached, and you will have to clear it from your storage/app/purify folder if you want to make changes to the definition.

Otherwise, you will have to change the definition version number or ID for it to be re-cached.
Thyrosis's avatar

Hi @nakov, that's exactly what my code does already.

I have an attribute on Post:

    public function getBodyAttribute($body)
    {
        return \Purify::clean($body);
    }

The clean-method can't be called without an argument. Upping the definition_rev number declared at the top of the PurifySetupProvider doesn't work either.

Thank you so much for your help so far! If you have any great ideas left, I'm more than happy to hear them.

Thyrosis's avatar

Okay, I've finally given up. Even with @nakov s help, I've come to the conclusion that sadly, there is no fixing this for me.

So, instead of having the target stored in the database inside the anchors, I've extracted it to some JavaScript code.

    <script type="text/javascript">
            var anchors = document.getElementsByTagName('a');
            for (var i in anchors) {
                if (anchors[i].href && anchors[i].href.indexOf('{{ config('app.url') }}') == -1) {
                    anchors[i].onclick = function () { return !window.open(this); };
                }
            }
        </script>

This little piece of code just loops through all anchors in the page and then checks the href. If the current app.url is not part of the href, it's an external link and it opens in a new window. If the current app.url is in fact in the href, it's a local link and so it just opens in the same window.

No, it's not foolproof and it takes away freedom from the user, but it 'works'. Still, if someone stumbles across this thread and knows the answer, shout away.

Nakov's avatar

@thyrosis as a last piece of advice, have you tried opening an issue on the github page for the library and see if some of the maintainers of the code would give you an answer?

Thyrosis's avatar

Right, just in case anybody stumbles across this post: there actually is a solution!

Came across it trying to sort out something else. Couldn't believe it though...

There's really a configuration value for it! Just add the following line to the settings array in config/purify.php:

        'HTML.TargetBlank' => true,

Documentation :

If enabled, target=blank attributes are added to all outgoing links. (This includes links from an HTTPS version of a page to an HTTP version.)

-facepalm-

Please or to participate in this conversation.