Passed
Push — main ( 1012d1...665b8f )
by Daniel
16:24
created

ComponentVoter::getPublishedSubject()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
cc 4
eloc 8
c 0
b 0
f 0
nc 3
nop 1
dl 0
loc 14
ccs 0
cts 9
cp 0
crap 20
rs 10
1
<?php
2
3
/*
4
 * This file is part of the Silverback API Components Bundle Project
5
 *
6
 * (c) Daniel West <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Silverback\ApiComponentsBundle\Security\Voter;
15
16
use ApiPlatform\Api\IriConverterInterface;
17
use Doctrine\Persistence\ManagerRegistry;
18
use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader;
19
use Silverback\ApiComponentsBundle\DataProvider\PageDataProvider;
20
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
21
use Silverback\ApiComponentsBundle\Entity\Core\AbstractPageData;
22
use Silverback\ApiComponentsBundle\Entity\Core\Route;
23
use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker;
24
use Silverback\ApiComponentsBundle\Utility\ClassMetadataTrait;
25
use Symfony\Component\HttpFoundation\Request;
26
use Symfony\Component\HttpFoundation\RequestStack;
27
use Symfony\Component\HttpFoundation\Response;
28
use Symfony\Component\HttpKernel\HttpKernelInterface;
29
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
30
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
31
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
32
33
/**
34
 * @author Daniel West <[email protected]>
35
 */
36
class ComponentVoter extends Voter
37
{
38
    use ClassMetadataTrait;
39
40
    public const READ_COMPONENT = 'read_component';
41
42
    public function __construct(
43
        private readonly PageDataProvider $pageDataProvider,
44
        private readonly IriConverterInterface $iriConverter,
45
        private readonly HttpKernelInterface $httpKernel,
46
        private readonly RequestStack $requestStack,
47
        private readonly PublishableStatusChecker $publishableStatusChecker,
48
        ManagerRegistry $registry
49
    ) {
50
        $this->initRegistry($registry);
51
    }
52
53
    protected function supports($attribute, $subject): bool
54
    {
55
        return self::READ_COMPONENT === $attribute && $subject instanceof AbstractComponent;
56
    }
57
58
    /**
59
     * @param AbstractComponent $subject
60
     */
61
    protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
62
    {
63
        $request = $this->requestStack->getCurrentRequest();
64
        if (!$request) {
65
            return true;
66
        }
67
68
        $subject = $this->getPublishedSubject($subject);
69
70
        $pagesGenerator = $this->getComponentPages($subject);
71
        $pages = iterator_to_array($pagesGenerator);
72
73
        // 1. Check if accessible via any route
74
        $routeVoteResult = $this->voteByRoute($pages, $request);
75
        if ($routeVoteResult) {
76
            return true;
77
        }
78
79
        // 2. as a page data property
80
        $pageDataResult = $this->voteByPageData($subject, $request);
81
        if ($pageDataResult) {
82
            return true;
83
        }
84
85
        // 3. as a component in the page template being used by page data
86
        $pageTemplateResult = $this->voteByPageTemplate($pages, $request);
87
        if ($pageTemplateResult) {
88
            return true;
89
        }
90
91
        // vote is ok if all sub votes abstain
92
        return null === $routeVoteResult && null === $pageDataResult && null === $pageTemplateResult;
93
    }
94
95
    private function voteByPageTemplate($pages, Request $request): ?bool
96
    {
97
        $pageDataByPagesComponentUsedIn = $this->pageDataProvider->findPageDataResourcesByPages($pages);
98
        foreach ($pageDataByPagesComponentUsedIn as $pageData) {
99
            if ($this->isPageDataReachableResource($pageData, $request)) {
100
                return true;
101
            }
102
        }
103
        return \count($pageDataByPagesComponentUsedIn) ? false : null;
104
    }
105
106
    private function voteByPageData($subject, Request $request): ?bool
107
    {
108
        $pageData = $this->pageDataProvider->findPageDataComponentMetadata($subject);
109
        $pageDataCount = 0;
110
        foreach ($pageData as $pageDatum) {
111
            foreach ($pageDatum->getPageDataResources() as $pageDataResource) {
112
                ++$pageDataCount;
113
                if ($this->isPageDataReachableResource($pageDataResource, $request)) {
114
                    return true;
115
                }
116
            }
117
        }
118
        return $pageDataCount ? false : null;
119
    }
120
121
    private function voteByRoute($pages, Request $request): ?bool
122
    {
123
        $routes = $this->getComponentRoutesFromPages($pages);
124
        $routeCount = 0;
125
        foreach ($routes as $route) {
126
            ++$routeCount;
127
            if ($this->isRouteReachableResource($route, $request)) {
128
                return true;
129
            }
130
        }
131
        return $routeCount ? false : null;
132
    }
133
134
135
    private function getPublishedSubject($subject)
136
    {
137
        // is a draft publishable. If a published version is available we should be checking the published version to see if it is in an accessible location
138
        $publishableAttributeReader = $this->publishableStatusChecker->getAttributeReader();
139
        if ($publishableAttributeReader->isConfigured($subject) && !$this->publishableStatusChecker->isActivePublishedAt($subject)) {
140
            $configuration = $publishableAttributeReader->getConfiguration($subject);
141
            $classMetadata = $this->getClassMetadata($subject);
142
143
            $publishedResourceAssociation = $classMetadata->getFieldValue($subject, $configuration->associationName);
144
            if ($publishedResourceAssociation) {
145
                return $publishedResourceAssociation;
146
            }
147
        }
148
        return $subject;
149
    }
150
151
    private function isRouteReachableResource(Route $route, Request $request): bool
152
    {
153
        $path = $this->iriConverter->getIriFromResource($route);
154
155
        return $this->isPathReachable($path, $request);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $path of Silverback\ApiComponents...oter::isPathReachable() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

155
        return $this->isPathReachable(/** @scrutinizer ignore-type */ $path, $request);
Loading history...
156
    }
157
158
    private function isPageDataReachableResource(AbstractPageData $pageData, Request $request): bool
159
    {
160
        $path = $this->iriConverter->getIriFromResource($pageData);
161
162
        return $this->isPathReachable($path, $request);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $path of Silverback\ApiComponents...oter::isPathReachable() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

162
        return $this->isPathReachable(/** @scrutinizer ignore-type */ $path, $request);
Loading history...
163
    }
164
165
    private function isPathReachable(string $path, Request $request): bool
166
    {
167
        $serverVars = $request->server->all();
168
        if (isset($serverVars['HTTP_ACCEPT'])) {
169
            $serverVars['HTTP_ACCEPT'] = 'application/ld+json,application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
170
        }
171
        $subRequest = Request::create(
172
            $path,
173
            Request::METHOD_GET,
174
            [],
175
            $request->cookies->all(),
176
            [],
177
            $serverVars,
178
            null
179
        );
180
181
        try {
182
            $this->httpKernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST, false);
183
184
            return true;
185
        } catch (\Exception $e) {
186
            // unsupported format requested
187
            if ($e instanceof NotEncodableValueException) {
188
                return false;
189
            }
190
            if (\in_array($e->getCode(), [Response::HTTP_UNAUTHORIZED, Response::HTTP_FORBIDDEN], true)) {
191
                return false;
192
            }
193
            throw $e;
194
        }
195
    }
196
197
    private function getComponentPages(AbstractComponent $component): \Traversable
198
    {
199
        $componentPositions = $component->getComponentPositions();
200
        if (!\count($componentPositions)) {
201
            return;
202
        }
203
204
        foreach ($componentPositions as $componentPosition) {
205
            $componentGroup = $componentPosition->componentGroup;
206
            foreach ($componentGroup->components as $parentComponent) {
207
                yield from $this->getComponentPages($parentComponent);
208
            }
209
            yield from $componentGroup->pages;
210
        }
211
    }
212
213
    private function getComponentRoutesFromPages(array $pages): iterable
214
    {
215
        foreach ($pages as $page) {
216
            $route = $page->getRoute();
217
            if ($route) {
218
                yield $route;
219
            }
220
        }
221
    }
222
}
223