Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 73
Statistics
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 5
992
0.00% covered (danger)
0.00%
0 / 73
 __construct
0.00% covered (danger)
0.00%
0 / 1
90
0.00% covered (danger)
0.00%
0 / 18
 getRouters
0.00% covered (danger)
0.00%
0 / 1
90
0.00% covered (danger)
0.00%
0 / 14
 getPostProcessors
0.00% covered (danger)
0.00%
0 / 1
30
0.00% covered (danger)
0.00%
0 / 11
 collectData
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 24
 getCountryByIp
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 6
1<?php
2
3namespace Miniframe\Statistics\Middleware;
4
5use GeoIp2\Database\Reader;
6use GeoIp2\Exception\AddressNotFoundException;
7use Miniframe\Core\AbstractMiddleware;
8use Miniframe\Core\Config;
9use Miniframe\Core\Request;
10use Miniframe\Core\Response;
11use Miniframe\Statistics\Model\PageHit;
12use Miniframe\Statistics\Service\Browscap;
13use Miniframe\Statistics\Service\Storage;
14use Miniframe\Toolbar\Service\DeveloperToolbar;
15use Miniframe\Statistics\Controller\Statistics as StatisticsController;
16
17class Statistics extends AbstractMiddleware
18{
19    /**
20     * Path to the country database
21     *
22     * @var string|null
23     */
24    protected $CountryMmdbFile;
25
26    /**
27     * Reference to the Browscap class
28     *
29     * @var Browscap
30     */
31    protected $browscap;
32
33    /**
34     * Reference to the storage for statistics.
35     *
36     * @var Storage
37     */
38    protected $storage;
39
40    /**
41     * URL prefix for statistic pages
42     *
43     * @var string
44     */
45    protected $statisticsPrefix = '_STATISTICS';
46
47    /**
48     * Set to true to ignore collecting data
49     *
50     * @var bool
51     */
52    protected $ignoreCollection = false;
53
54    /**
55     * Initiates the Statistics middleware
56     *
57     * @param Request $request Reference to the Request object.
58     * @param Config  $config  Reference to the Config object.
59     */
60    public function __construct(Request $request, Config $config)
61    {
62        parent::__construct($request, $config);
63
64        // If there's a slug set, overwrite the current prefix
65        if ($config->has('statistics', 'slug')) {
66            $this->statisticsPrefix = $config->get('statistics', 'slug');
67            if (!preg_match('/^[a-z0-9\_\-]+$/i', $this->statisticsPrefix)) {
68                throw new \InvalidArgumentException('Slug has illegal characters');
69            }
70        }
71
72        // We won't log console requests
73        if ($this->request->isShellRequest()) {
74            $this->ignoreCollection = true;
75            return;
76        }
77
78        $this->storage = new Storage($config->getPath('statistics', 'storage_path'));
79
80        // Configure and validate GeoIP database
81        if (class_exists(Reader::class) && $config->has('statistics', 'geoip_database_path')) {
82            $this->CountryMmdbFile = $config->getPath('statistics', 'geoip_database_path')
83                . '/GeoLite2-Country.mmdb';
84            if (!file_exists($this->CountryMmdbFile)) {
85                throw new \RuntimeException('No geoip database found. Please run the update command');
86            }
87        }
88
89        // Validate browscap database
90        $path = $this->config->getPath('statistics', 'browscap_database_path');
91        $this->browscap = new Browscap($path);
92        if (!ini_get('browscap')) {
93            if (!file_exists($path . '/browscap.php')) {
94                throw new \RuntimeException('No browscap database found. Please run the update command');
95            }
96        }
97    }
98
99    /**
100     * Handles the statistics pages
101     *
102     * For a good example, see ../Middleware/UrlToMvcRouter.php
103     *
104     * @return callable[]
105     */
106    public function getRouters(): array
107    {
108        return [function () {
109            $path = $this->request->getPath();
110
111            // Remove base path, if present
112            $basePath = explode('/', trim(parse_url($this->config->get('framework', 'base_href'), PHP_URL_PATH), '/'));
113            foreach ($basePath as $prefix) {
114                if (isset($path[0]) && $path[0] == $prefix) {
115                    array_shift($path);
116                    $path = array_values($path); // Reset indexes
117                }
118            }
119
120            if (count($path) == 1 && $path[0] == $this->statisticsPrefix) {
121                $this->ignoreCollection = true;
122                return [new StatisticsController($this->request, $this->config), 'main'];
123            } elseif (
124                count($path) > 1
125                && $path[0] == $this->statisticsPrefix
126                && method_exists(StatisticsController::class, $path[1])
127            ) {
128                $this->ignoreCollection = true;
129                return [new StatisticsController($this->request, $this->config), $path[1]];
130            }
131
132            return null;
133        }];
134    }
135
136    /**
137     * Adds the post processor that triggers data logging
138     *
139     * @return callable[]
140     */
141    public function getPostProcessors(): array
142    {
143        return [function (Response $response): Response {
144            // Collecting can be ignored by request
145            if ($this->ignoreCollection) {
146                return $response;
147            }
148
149            try {
150                $pageHit = $this->collectData($response);
151                if ($pageHit) {
152                    $this->storage->store($pageHit);
153                }
154            } catch (\Throwable $throwable) {
155                // Ignore; we don't want to break the site for statistical purposes
156                if (class_exists(DeveloperToolbar::class)) {
157                    DeveloperToolbar::getInstance()->dump([
158                        $throwable->getMessage(), $throwable->getFile(), $throwable->getLine()
159                    ]);
160                }
161            }
162            return $response;
163        }];
164    }
165
166    /**
167     * Collects data based on the Response class and the available Request object
168     *
169     * @param Response $response The response object.
170     *
171     * @return PageHit|null
172     */
173    protected function collectData(Response $response): ?PageHit
174    {
175        if ($response->getResponseCode() != 200) {
176            return null;
177        }
178
179        $dateTime = new \DateTime();
180        $requestUri = $this->request->getServer('REQUEST_URI');
181        $userAgent = $this->request->getServer('HTTP_USER_AGENT');
182        $remoteAddress = $this->request->getServer('REMOTE_ADDR');
183        $country = $this->getCountryByIp($remoteAddress);
184        $browserInfo = $this->browscap->getBrowser($userAgent);
185        $browserBrand = $browserInfo['Browser'] ?? null;
186        $browserVersion = $browserInfo['Version'] ?? null;
187        $platform = $browserInfo['Platform'] ?? null;
188        $deviceType = $browserInfo['Device_Type'] ?? null;
189
190        // When the referrer has the same hostname as the current domain, it's not a new visitor
191        $newVisitor = true;
192        $referrer = $this->request->getServer('HTTP_REFERER');
193        if ($referrer !== null) {
194            $referrerHost = parse_url($referrer, PHP_URL_HOST);
195            $serverHost = $this->request->getServer('HTTP_HOST');
196            if ($referrerHost == $serverHost) {
197                $newVisitor = false;
198            }
199        }
200
201        // Remove data from memory; we are not going to process this info, and its personal data.
202        $remoteAddress = null;
203        $userAgent = null;
204        $browserInfo = null;
205        $referrer = null;
206
207        return new PageHit(
208            $dateTime,
209            $newVisitor,
210            $requestUri,
211            $country,
212            $browserBrand,
213            $browserVersion,
214            $platform,
215            $deviceType
216        );
217    }
218
219    /**
220     * Returns the ISO code of a country (or null) for a specific IP address
221     *
222     * @param string $ip The IP address.
223     *
224     * @return string|null
225     */
226    protected function getCountryByIp(string $ip): ?string
227    {
228        // Fetch country code based on IP address
229        if (class_exists(Reader::class) && $this->CountryMmdbFile) {
230            $reader = new Reader($this->CountryMmdbFile);
231            try {
232                return $reader->country($ip)->country->isoCode;
233            } catch (AddressNotFoundException $exception) {
234                // Ignore, returning null in the end
235            }
236        }
237        return null;
238    }
239}