Passed
Push — master ( 28a571...61bf45 )
by MusikAnimal
30:58 queued 28:02
created

DefaultController::getFilenameForRequest()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace App\Controller;
6
7
use App\Model\Query;
8
use App\Repository\CloudElasticRepository;
9
use App\Repository\WikiDomainRepository;
10
use GuzzleHttp\Client;
11
use Psr\Cache\CacheItemPoolInterface;
12
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
13
use Symfony\Component\HttpFoundation\JsonResponse;
14
use Symfony\Component\HttpFoundation\Request;
15
use Symfony\Component\HttpFoundation\Response;
16
use Symfony\Component\Routing\Annotation\Route;
17
18
/**
19
 * A DefaultController serves the main routes for the application and processing the submitted form.
20
 */
21
class DefaultController extends AbstractController
22
{
23
    /** @var Client */
24
    private $client;
25
26
    /** @var CacheItemPoolInterface */
27
    private $cache;
28
29
    /** @var bool Whether the results were pulled from cache. */
30
    private $fromCache = false;
31
32
    /** @var string Duration of cache for main results set, as accepted by DateInterval::createFromDateString() */
33
    private const CACHE_TIME = '10 minutes';
34
35
    /** @var string[]|null Map from wiki dbname to domain name */
36
    private $domainLookup;
37
38
    /**
39
     * DefaultController constructor.
40
     * @param CacheItemPoolInterface $cache
41
     */
42
    public function __construct(CacheItemPoolInterface $cache)
43
    {
44
        $this->client = new Client([
45
            'verify' => $_ENV['ELASTIC_INSECURE'] ? false : true,
46
        ]);
47
        $this->cache = $cache;
48
    }
49
50
    /**
51
     * Splash page, shown when user is logged out.
52
     * @Route("/splash")
53
     */
54
    public function splashAction(): Response
55
    {
56
        return $this->render('jumbotron.html.twig');
57
    }
58
59
    /**
60
     * The main route.
61
     * @Route("/", name="home")
62
     * @param Request $request
63
     * @return Response
64
     */
65
    public function indexAction(Request $request): Response
66
    {
67
        if (!$this->get('session')->get('logged_in_user')) {
68
            return $this->render('jumbotron.html.twig');
69
        }
70
        $query = $request->query->get('q');
71
        $regex = (bool)$request->query->get('regex');
72
        $ignoreCase = (bool)$request->query->get('ignorecase');
73
        [$namespaces, $namespaceIds] = $this->parseNamespaces($request);
74
        $purgeCache = (bool)$request->query->get('purge');
75
76
        $ret = [
77
            'q' => $query,
78
            'regex' => $regex,
79
            'max_results' => Query::MAX_RESULTS,
80
            'namespaces' => $namespaceIds,
81
            'ignore_case' => $ignoreCase,
82
        ];
83
84
        if ($query) {
85
            $ret = array_merge($ret, $this->getResults($query, $regex, $ignoreCase, $namespaceIds, $purgeCache));
86
            $ret['from_cache'] = $this->fromCache;
87
            return $this->formatResponse($request, $query, $ret);
88
        }
89
90
        return $this->render('default/index.html.twig', $ret);
91
    }
92
93
    /**
94
     * Get the rendered template for the requested format.
95
     * @param Request $request
96
     * @param string $query Query string, used for filenames.
97
     * @param array $data Data that should be passed to the view.
98
     * @return Response
99
     */
100
    private function formatResponse(Request $request, string $query, array $data): Response
101
    {
102
        $format = $request->query->get('format', 'html');
103
        if ('' == $format) {
104
            // The default above doesn't work when the 'format' parameter is blank.
105
            $format = 'html';
106
        }
107
108
        $formatMap = [
109
            'html' => 'text/html',
110
            'wikitext' => 'text/plain',
111
            'csv' => 'text/csv',
112
            'tsv' => 'text/tab-separated-values',
113
            'json' => 'application/json',
114
        ];
115
116
        // Use HTML if unknown format requested.
117
        $format = isset($formatMap[$format]) ? $format : 'html';
118
119
        // Nothing more needed if requesting JSON.
120
        if ('json' === $format) {
121
            return new JsonResponse($data);
122
        }
123
124
        $response = new Response();
125
126
        $response->headers->set('Content-Type', $formatMap[$format]);
127
        if (in_array($format, ['csv', 'tsv'])) {
128
            $filename = $this->getFilenameForRequest($query);
129
            $response->headers->set(
130
                'Content-Disposition',
131
                "attachment; filename=\"{$filename}.$format\""
132
            );
133
        }
134
135
        return $this->render("default/result.$format.twig", $data, $response);
136
    }
137
138
    /**
139
     * Returns pretty filename from the given query, with problematic characters filtered out.
140
     * @param string $query
141
     * @return string
142
     */
143
    private function getFilenameForRequest(string $query): string
144
    {
145
        $filename = trim($query, '/');
146
        return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename));
147
    }
148
149
    /**
150
     * Get results based on given Request.
151
     * @param string $query
152
     * @param bool $regex
153
     * @param bool $ignoreCase
154
     * @param int[] $namespaceIds
155
     * @param bool $purgeCache
156
     * @return mixed[]
157
     */
158
    public function getResults(
159
        string $query,
160
        bool $regex,
161
        bool $ignoreCase,
162
        array $namespaceIds,
163
        bool $purgeCache = false
164
    ): array {
165
        $cacheItem = md5($query.$regex.$ignoreCase.implode('|', $namespaceIds));
166
167
        if (!$purgeCache && $this->cache->hasItem($cacheItem)) {
168
            $this->fromCache = true;
169
            return $this->cache->getItem($cacheItem)->get();
170
        }
171
172
        $query = new Query($query, $namespaceIds, $regex, $ignoreCase);
173
        $params = $query->getParams();
174
        $res = (new CloudElasticRepository($this->client, $params))->makeRequest();
175
        $data = [
176
            'regex' => $regex,
177
            'ignore_case' => $ignoreCase,
178
            'total' => $res['hits']['total'],
179
            'hits' => $this->formatHits($res),
180
        ];
181
182
        $cacheItem = $this->cache->getItem($cacheItem)
183
            ->set($data)
184
            ->expiresAfter(\DateInterval::createFromDateString(self::CACHE_TIME));
185
        $this->cache->save($cacheItem);
186
        return $data;
187
    }
188
189
    /**
190
     * Build the data structure for each hit, giving the view what it needs.
191
     * @param mixed[] $data
192
     * @return mixed[]
193
     */
194
    private function formatHits(array $data): array
195
    {
196
        $hits = $data['hits']['hits'];
197
        $newData = [];
198
199
        foreach ($hits as $hit) {
200
            $result = $hit['_source'];
201
            $title = ($result['namespace_text'] ? $result['namespace_text'].':' : '').$result['title'];
202
            $domain = $this->getWikiDomainFromDbName($result['wiki']);
203
            $newData[] = [
204
                'wiki' => rtrim($domain, '.org'),
205
                'title' => $title,
206
                'url' => $this->getUrlForTitle($domain, $title),
207
                'source_text' => $this->highlightQuery(
208
                    $hit['highlight']['source_text.plain'][0] ?? ''
209
                ),
210
            ];
211
        }
212
213
        return $newData;
214
    }
215
216
    /**
217
     * Get the URL to the page with the given title on the given wiki.
218
     * @param string $domain
219
     * @param string $title
220
     * @return string
221
     */
222
    private function getUrlForTitle(string $domain, string $title): string
223
    {
224
        return 'https://'.$domain.'/wiki/'.$title;
225
    }
226
227
    /**
228
     * Query Siteinfo API to get the domain of the wiki with the given database name.
229
     * @param string $wiki
230
     * @return string
231
     */
232
    private function getWikiDomainFromDbName(string $wiki): string
233
    {
234
        if (null === $this->domainLookup) {
235
            $this->domainLookup = (new WikiDomainRepository($this->client, $this->cache))->load();
236
        }
237
        return $this->domainLookup[$wiki] ?? 'WIKINOTFOUND';
238
    }
239
240
    /**
241
     * Make the highlight text safe and wrap the search term in a span so that we can style it.
242
     * @param string $text
243
     * @return string
244
     */
245
    private function highlightQuery(string $text): string
246
    {
247
        $text = htmlspecialchars($text);
248
        return strtr($text, [
249
            Query::PRE_TAG => "<span class='highlight'>",
250
            Query::POST_TAG => "</span>",
251
        ]);
252
    }
253
254
    /**
255
     * Parse the namespaces parameter of the query string.
256
     * @param Request $request
257
     * @return mixed[] [normalized comma-separated list as a string, array of ids as ints]
258
     */
259
    private function parseNamespaces(Request $request): array
260
    {
261
        $param = $request->query->get('namespaces', '');
262
263
        if ('' === $param) {
264
            $ids = [];
265
        } else {
266
            $ids = array_map(
267
                'intval',
268
                explode(',', $param)
269
            );
270
        }
271
272
        return [
273
            implode(',', $ids),
274
            $ids,
275
        ];
276
    }
277
}
278