Passed
Pull Request — master (#17)
by BENOIT
02:04
created

SubscriptionsController::filterSubscriptions()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 20
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 12
nc 1
nop 3
dl 0
loc 20
rs 9.8666
c 0
b 0
f 0
1
<?php
2
3
namespace BenTools\MercurePHP\Controller;
4
5
use BenTools\MercurePHP\Exception\Http\AccessDeniedHttpException;
6
use BenTools\MercurePHP\Hub\Hub;
7
use BenTools\MercurePHP\Model\Subscription;
8
use BenTools\MercurePHP\Security\Authenticator;
9
use BenTools\MercurePHP\Security\TopicMatcher;
10
use BenTools\MercurePHP\Storage\StorageInterface;
11
use Psr\Http\Message\RequestInterface;
12
use Psr\Http\Message\ResponseInterface;
13
use Psr\Http\Message\ServerRequestInterface;
14
use React\Http\Message\Response;
15
use React\Stream\ThroughStream as Stream;
16
17
use function BenTools\MercurePHP\nullify;
18
19
final class SubscriptionsController extends AbstractController
20
{
21
    private const PATH = '/.well-known/mercure/subscriptions';
22
    private const TOPIC_SELECTOR = '/.well-known/mercure/subscriptions/{topic}';
23
    private const TOPIC_AND_SUBSCRIBER_SELECTOR = '/.well-known/mercure/subscriptions/{topic}/{subscriber}';
24
    private Hub $hub;
25
    private Authenticator $authenticator;
26
27
    public function __construct(Hub $hub, Authenticator $authenticator)
28
    {
29
        $this->hub = $hub;
30
        $this->authenticator = $authenticator;
31
    }
32
33
    public function __invoke(ServerRequestInterface $request): ResponseInterface
34
    {
35
        if (!\in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
36
            return new Response(405);
37
        }
38
39
        $path = $request->getUri()->getPath();
40
        [$topicFilter, $subscriberFilter] = $this->extractFilters($request);
41
        $token = $this->authenticator->authenticate($request);
42
43
        if (null === $token) {
44
            throw new AccessDeniedHttpException('You must be authenticated to access the Subscription API.');
45
        }
46
47
        $claim = (array) $token->getClaim('mercure');
48
        $allowedTopics = $claim['subscribe'] ?? [];
49
        $deniedTopics = $claim['subscribe_exclude'] ?? [];
50
        $matchAllowedTopics = TopicMatcher::matchesTopicSelectors($path, $allowedTopics);
51
        $matchDeniedTopics = TopicMatcher::matchesTopicSelectors($path, $deniedTopics);
52
        if (!$matchAllowedTopics || $matchDeniedTopics) {
53
            throw new AccessDeniedHttpException('You are not authorized to display these subscriptions.');
54
        }
55
56
        $stream = new Stream();
57
        $this->sendSubscriptionsList($stream, $path, $topicFilter, $subscriberFilter, $allowedTopics, $deniedTopics);
58
59
        $headers = [
60
            'Content-Type' => 'application/ld+json',
61
        ];
62
63
        return new Response(200, $headers, $stream);
64
    }
65
66
    public function matchRequest(RequestInterface $request): bool
67
    {
68
        [$topic, $subscriber] = $this->extractFilters($request);
69
        $hasTopicPattern = false !== \strpos($topic ?? '', '{');
70
        $hasSubscriberPattern = false !== \strpos($subscriber ?? '', '{');
71
        $hasBothFilters = null !== $topic && null !== $subscriber;
72
        $hasNoPattern = !$hasTopicPattern && !$hasSubscriberPattern;
73
74
        $isSubscriptionIRI = $hasBothFilters && $hasNoPattern;
75
76
        return $this->matchesPattern($request) && !$isSubscriptionIRI;
77
    }
78
79
    private function matchesPattern(RequestInterface $request): bool
80
    {
81
        $path = $request->getUri()->getPath();
82
83
        return TopicMatcher::matchesTopicSelectors($path, [
84
            self::PATH,
85
            self::TOPIC_SELECTOR,
86
            self::TOPIC_AND_SUBSCRIBER_SELECTOR,
87
        ]);
88
    }
89
90
    private function extractFilters(RequestInterface $request): array
91
    {
92
        $path = $request->getUri()->getPath();
93
        $filters = \explode('/', \trim(\strtr($path, [self::PATH => '']), '/'), 2);
94
        $topic = nullify(\urldecode($filters[0] ?? ''));
95
        $subscriber = nullify(\urldecode($filters[1] ?? ''));
96
97
        return [$topic, $subscriber];
98
    }
99
100
    private function sendSubscriptionsList(
101
        Stream $stream,
102
        string $path,
103
        ?string $topic,
104
        ?string $subscriber,
105
        array $allowedTopics,
106
        array $deniedTopics
107
    ): void {
108
        $this->hub->hook(
109
            function () use ($stream, $path, $subscriber, $topic, $allowedTopics, $deniedTopics) {
110
                $this->hub->getLastEventID()->then(
111
                    function (?string $lastEventId) use (
112
                        $topic,
113
                        $subscriber,
114
                        $stream,
115
                        $path,
116
                        $allowedTopics,
117
                        $deniedTopics
118
                    ) {
119
                        $this->hub->getActiveSubscriptions($topic, $subscriber)
120
                            ->then(
121
                                function (iterable $subscriptions) use (
122
                                    $stream,
123
                                    $path,
124
                                    $allowedTopics,
125
                                    $deniedTopics,
126
                                    $lastEventId
127
                                ) {
128
                                    $subscriptions = $this->filterSubscriptions(
129
                                        $subscriptions,
130
                                        $allowedTopics,
131
                                        $deniedTopics
132
                                    );
133
                                    $result = [
134
                                        '@context' => 'https://mercure.rocks/',
135
                                        'id' => $path,
136
                                        'type' => 'Subscriptions',
137
                                        'lastEventID' => $lastEventId ?? StorageInterface::EARLIEST,
138
                                        'subscriptions' => $subscriptions,
139
                                    ];
140
                                    $this->sendResult($stream, $result);
141
                                }
142
                            );
143
                    }
144
                );
145
            }
146
        );
147
    }
148
149
    private function sendResult(Stream $stream, array $result): void
150
    {
151
        $stream->write(\json_encode($result, \JSON_THROW_ON_ERROR));
152
        $stream->end();
153
        $stream->close();
154
    }
155
156
    private function filterSubscriptions(iterable $subscriptions, array $allowedTopics, array $deniedTopics): array
157
    {
158
        $subscriptions = \iterable_to_array($subscriptions);
159
        $subscriptions = \array_filter(
160
            $subscriptions,
161
            function (Subscription $subscription) use ($allowedTopics, $deniedTopics) {
162
                $matchAllowedTopics = TopicMatcher::matchesTopicSelectors(
163
                    $subscription->getId(),
164
                    $allowedTopics
165
                );
166
                $matchDeniedTopics = TopicMatcher::matchesTopicSelectors(
167
                    $subscription->getId(),
168
                    $deniedTopics
169
                );
170
171
                return $matchAllowedTopics && !$matchDeniedTopics;
172
            }
173
        );
174
175
        return \array_values($subscriptions);
176
    }
177
}
178