neos / neos-development-collection

The unified repository containing the Neos core packages, used for Neos development.
https://www.neos.io/
GNU General Public License v3.0
260 stars 222 forks source link

BUG: Asset redirects too unflexible for cloud environments #4815

Open kitsunet opened 9 months ago

kitsunet commented 9 months ago

Is there an existing issue for this?

Current Behavior

If you replace an asset resource via the media UI (or code) a check for the existence of RedirectStorageInterface and the respective option for asset redirects is made and an redirect entry for the two (old/new) asset Uris is created. This will only work on local systems with a file try and fallback approach on the webserver (so first the requested old resource uri is tried by the web server and if that fails - as the resource is no longer there - it gives the request to index.php and it eventually ends up in the redirect handler). This cannot work if your assets are stored in a cloud storage as there cannot be a file existence check and fallback from the webserver in this case. Also the implementation with a code (soft) dependency on RedirectStorageInterface could be cleaner. See Neos\Media\Domain\Service\AssetService::replaceAssetResource

Expected Behavior

We can use asset redirects also for cloud storages or generally other configurations than the one described above.

My suggested plan is:

Add interface to Media (rough name idea AssetRedirectHandlerInterface) that provides a method to handle possible asset redirects Add Service to Media that selects a matching implementation of above interface for the ResourceTarget of "old asset resource" with a possible default fallback. Add interface implementation to Redirect (Storage) package that does about the same as now the Neos\Media\Domain\Service\AssetService::replaceAssetResource does Cloud Target implementations can then add their own implementations.

Steps To Reproduce

Environment

doesn't matter / any

Anything else?

No response

kitsunet commented 9 months ago

@rolandschuetz I created this after looking into it. It could be done via AOP but it's quite dirty. I guess a workaround for older installations might be a command tool that converts resource redirects from the database storage to ones that work with the cloud storage but no "live" integration. Could potentially be done in a cron job then. But I would focus to create this infrastructure for Neos 9.0 now.

rolandschuetz commented 7 months ago

Our workaround: Maybe this helps someone in a similar situation. So as an intermediate solution for our customers on Neos 8 we do not add final uris to assets in the content anymore, instead we create links to a custom controller, that then redirects to our current asset link.

The code to achieve this:

DistributionPackages/CodeQ.Site/Classes/Aop/ConvertUrisImplementationAspect.php

<?php

namespace CodeQ\Site\Aop;

use Neos\Flow\Annotations as Flow;
use Neos\Flow\Aop\JoinPointInterface;

/**
 * @Flow\Aspect
 */
class ConvertUrisImplementationAspect
{
    /**
     * Adds target="_blank" if its an asset permalink
     *
     * @Flow\Around("method(Neos\Neos\Fusion\ConvertUrisImplementation->replaceLinkTargets())")
     * @param  JoinPointInterface  $joinPoint
     * @return string
     */
    public function aroundReplaceLinkTargets(JoinPointInterface $joinPoint): string
    {
        $processedContent = $joinPoint->getAdviceChain()->proceed($joinPoint);
        return \preg_replace_callback(
            '~<a\s+.*?href="(.*?)/_asset/(.*?)".*?>~i',
            static function ($matches) {
                [$linkText] = $matches;
                return self::setAttribute('target', '_blank', $linkText);
            },
            $processedContent
        );
    }

    /**
     * Taken from \Neos\Neos\Fusion\ConvertUrisImplementation
     *
     * Set or add value to the a attribute
     *
     * @param string $attribute The attribute, ('target' or 'rel')
     * @param string $value The value of the attribute to add
     * @param string $content The content to parse
     * @return string
     */
    private static function setAttribute(string $attribute, string $value, string $content): string
    {
        // The attribute is already set
        if (\preg_match_all('~\s+' . $attribute . '="(.*?)~i', $content, $matches)) {
            // If the attribute is target or the value is already set, leave the attribute as it is
            if ($attribute === 'target' || \preg_match('~' . $attribute . '=".*?' . $value . '.*?"~i', $content)) {
                return $content;
            }
            // Add the attribute to the list
            return \preg_replace('/' . $attribute . '="(.*?)"/', sprintf('%s="$1 %s"', $attribute, $value), $content);
        }

        // Add the missing attribute with the value
        return \str_replace('<a', sprintf('<a %s="%s"', $attribute, $value), $content);
    }
}

DistributionPackages/CodeQ.Site/Classes/Aop/LinkingServiceAspect.php

<?php

namespace CodeQ\Site\Aop;

use Neos\Flow\Annotations as Flow;
use Neos\Flow\Aop\JoinPointInterface;
use Neos\Flow\Http\Exception;
use Neos\Flow\Log\Utility\LogEnvironment;
use Neos\Flow\Mvc\ActionRequestFactory;
use Neos\Flow\Mvc\Exception\InvalidActionNameException;
use Neos\Flow\Mvc\Exception\InvalidArgumentNameException;
use Neos\Flow\Mvc\Exception\InvalidArgumentTypeException;
use Neos\Flow\Mvc\Exception\InvalidControllerNameException;
use Neos\Flow\Mvc\Routing\Exception\MissingActionNameException;
use Neos\Flow\Mvc\Routing\UriBuilder;
use Neos\Http\Factories\ServerRequestFactory;
use Neos\Http\Factories\UriFactory;
use Neos\Neos\Service\LinkingService;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;

/**
 * @Flow\Aspect
 */
class LinkingServiceAspect
{
    /**
     * @param  JoinPointInterface  $joinPoint
     * @return string|null
     *
     * @Flow\Around("method(Neos\Neos\Service\LinkingService->resolveAssetUri())")
     */
    public function aroundResolveAssetUri(JoinPointInterface $joinPoint)
    {
        /** @var string|UriInterface $uri */
        $uri = $joinPoint->getMethodArgument('uri');
        /** @var LinkingService $linkingService */
        $linkingService = $joinPoint->getProxy();
        $targetObject = $linkingService->convertUriToObject($uri);
        if ($targetObject === null) {
            // error case is handled by the original method
            return $joinPoint->getAdviceChain()->proceed($joinPoint);
        }
        try {
            $uriFactory = new UriFactory();
            $httpRequest = (new ServerRequestFactory($uriFactory))->createServerRequest('GET', '');
            $request = (new ActionRequestFactory())->createActionRequest($httpRequest);
            $uriBuilder = new UriBuilder();
            $uriBuilder->setRequest($request);
            return $uriBuilder->uriFor('index', ['asset' => $targetObject], 'AssetProxy', 'CodeQ.Site');
        } catch (Exception|MissingActionNameException|InvalidArgumentNameException|InvalidActionNameException|InvalidArgumentTypeException|InvalidControllerNameException $e) {
        }

        return $joinPoint->getAdviceChain()->proceed($joinPoint);
    }
}

DistributionPackages/CodeQ.Site/Classes/Controller/AssetProxyController.php

<?php

namespace CodeQ\Site\Controller;

use Neos\Flow\Annotations as Flow;
use Neos\Flow\Mvc\Controller\ActionController;
use Neos\Flow\ResourceManagement\ResourceManager;
use Neos\Media\Domain\Model\Asset;

class AssetProxyController extends ActionController
{
    /**
     * @Flow\Inject
     * @var ResourceManager
     */
    protected $resourceManager;
    public function indexAction(Asset $asset): void
    {
        $publicUri = $this->resourceManager->getPublicPersistentResourceUri($asset->getResource());
        // The codes 200, 301 and 302 are cached by the Nginx proxy
        $this->redirectToUri($publicUri, 0, 302);
    }
}

DistributionPackages/CodeQ.Site/Configuration/Policy.yaml

privilegeTargets:
  'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege':
    'CodeQ.Site:AssetProxyController':
      label: 'Can access all AssetProxyController actions'
      matcher: 'within(CodeQ\Site\Controller\AssetProxyController)'

roles:
  'Neos.Flow:Everybody':
    privileges:
      - privilegeTarget: 'CodeQ.Site:AssetProxyController'
        permission: GRANT

DistributionPackages/CodeQ.Site/Configuration/Routes.yaml


roles:
  'Neos.Flow:Everybody':
    privileges:
      - privilegeTarget: 'CodeQ.Site:FeedbackController.submit'
        permission: GRANT
      - privilegeTarget: 'CodeQ.Site:VertiGis.all'
        permission: GRANT
      - privilegeTarget: 'CodeQ.Site:AssetProxyController'
        permission: GRANT