bnomei/kirby3-utm

View on GitHub
classes/Utm.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php

declare(strict_types=1);

namespace Bnomei;

use DeviceDetector\DeviceDetector;
use Exception;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Kirby\Database\Database;
use Kirby\Filesystem\F;
use Kirby\Http\Remote;
use Kirby\Toolkit\A;

final class Utm
{
    /** @var Database */
    private $_database;

    /** @var array $options */
    private $options;

    public function __construct(array $options = [])
    {
        $defaults = [
            'debug' => option('debug'),
            'enabled' => option('bnomei.utm.enabled'),
            'file' => option('bnomei.utm.sqlite.file'),
            'ip' => null, // INTERNAL: only used in testing via $options overwrite
            'ipstack_access_key' => option('bnomei.utm.ipstack.access_key'),
            'ipstack_https' => option('bnomei.utm.ipstack.https') ? 'https' : 'http',
            'ipstack_expire' => option('bnomei.utm.ipstack.expire'),
            'stats_range' => option('bnomei.utm.stats.range'),
            'ratelimit_enabled' => option('bnomei.utm.ratelimit.enabled'),
            'ratelimit_expire' => option('bnomei.utm.ratelimit.duration'),
            'ratelimit_trials' => option('bnomei.utm.ratelimit.trials'),
            'CrawlerDetect' => option('bnomei.utm.botDetection.CrawlerDetect'),
            'DeviceDetector' => option('bnomei.utm.botDetection.DeviceDetector'),
        ];
        $this->options = array_merge($defaults, $options);

        foreach ($this->options as $key => $call) {
            if (!is_string($call) && is_callable($call) && in_array($key, ['ip', 'ipstack_access_key', 'enabled', 'file'])) {
                $this->options[$key] = $call();
            }
        }

        if ($this->option('debug')) {
            try {
                kirby()->cache('bnomei.utm')->flush();
            } catch (Exception $e) {
                //
            }
        }

        // db is lazy loaded on first call
    }

    /**
     * @param string|null $key
     * @return array|mixed
     */
    public function option(?string $key = null)
    {
        if ($key) {
            return A::get($this->options, $key);
        }
        return $this->options;
    }

    public function databaseFile(): string
    {
        return $this->option('file');
    }

    public function database(): Database
    {
        // lazy load the db as late, so its not loaded if disabled
        if (!$this->_database) {
            $target = $this->databaseFile();
            if (!F::exists($target)) {
                $db = new \SQLite3($target);
                $db->exec("CREATE TABLE IF NOT EXISTS utm (ID INTEGER PRIMARY KEY AUTOINCREMENT, page_id TEXT NOT NULL, utm_source TEXT, utm_medium TEXT, utm_campaign TEXT, utm_term TEXT, utm_content TEXT, visited_at DATETIME DEFAULT CURRENT_TIMESTAMP, iphash TEXT, country_name TEXT, city TEXT, user_agent TEXT)");
                $db->close();
            }

            $this->_database = new Database([
                'type' => 'sqlite',
                'database' => $target,
            ]);
        }
        return $this->_database;
    }

    public function track(string $id, array $params): bool
    {
        if ($this->option('enabled') !== true) {
            return false;
        }

        $params = $this->sanitize($params);

        if (count($params) === 0) {
            return false; // no UTM params at all
        }

        $ip = $this->option('ip') ?? kirby()->visitor()->ip();
        $iphash = sha1(__DIR__ . $ip);

        // check rate limit
        if ($this->ratelimit($iphash) === false) {
            return false;
        }

        $useragent = A::get($_SERVER, "HTTP_USER_AGENT", '');

        if ($this->option('CrawlerDetect')) {
            $isCrawler = (new CrawlerDetect())->isCrawler($useragent);  // ~10ms
            if ($isCrawler) {
                return false;
            }
        }

        if ($this->option('DeviceDetector')) {
            $device = new DeviceDetector($useragent);
            $device->discardBotInformation();
            $device->parse(); // ~40ms
            if ($device->isBot()) {
                return false;
            }
        }

        $ipdata = $this->ipstack($ip, $iphash);
        $generated = [
            'visited_at' => time(),
            'iphash' => $iphash,
            'country' => A::get($ipdata, 'country_name', ''),
            'city' => A::get($ipdata, 'city', ''),
            'useragent' => $this->useragent(),
        ];

        // allow generated to be overwritten by input params (for testing etc)
        $params = array_merge($generated, $params);

        // retrieve again after merging
        $utm_source = A::get($params, 'utm_source', '');
        $utm_medium = A::get($params, 'utm_medium', '');
        $utm_campaign = A::get($params, 'utm_campaign', '');
        $utm_term = A::get($params, 'utm_term', '');
        $utm_content = A::get($params, 'utm_content', '');
        $visited_at = A::get($params, 'visited_at', '');
        if (is_int($visited_at)) {
            $visited_at = date('Y-m-d H:i:s', $visited_at);
        }
        $iphash = A::get($params, 'iphash', '');
        $country = A::get($params, 'country', '');
        $city = A::get($params, 'city', '');
        $useragent = A::get($params, 'useragent', '');

        $this->database()->query("INSERT INTO utm (page_id, utm_source, utm_medium, utm_campaign, utm_term, utm_content, visited_at, iphash, country_name, city, user_agent) VALUES ('$id', '$utm_source', '$utm_medium', '$utm_campaign', '$utm_term', '$utm_content', '$visited_at', '$iphash', '$country', '$city', '$useragent')");

        kirby()->cache('bnomei.utm.queries')->flush();

        return true;
    }

    public function count(string $query = 'SELECT count(*) AS count FROM utm'): int
    {
        if ($this->option('enabled') !== true) {
            return 0;
        }

        $key = md5($query) . '-count';
        $cache = kirby()->cache('bnomei.utm.queries');
        if ($data = $cache->get($key)) {
            return $data;
        }

        $count = intval($this->database()->query($query)->first()->count);
        $cache->set($key, $count);

        return $count;
    }

    public function useragent(): string
    {
        $ua = strtolower(A::get($_SERVER, "HTTP_USER_AGENT", ''));
        $isMob = is_numeric(strpos($ua, "mobile"));
        if ($isMob) {
            return 'mobile';
        }
        $isTab = is_numeric(strpos($ua, "tablet"));
        if ($isTab) {
            return 'tablet';
        }
        // $isDesk = !$isMob && !$isTab;

        return 'desktop';
    }

    public function ipstack(string $ip, string $iphash = null): array
    {
        $key = $this->option('ipstack_access_key');

        // ip could be empty on unittests
        if (empty($ip) || empty($key) || $this->option('enabled') !== true) {
            return [];
        }

        $cache = kirby()->cache('bnomei.utm.ipstack');
        $iphash ??= sha1(__DIR__ . $ip);
        if ($data = $cache->get($iphash)) {
            return $data;
        }

        $https = $this->option('ipstack_https');
        $url = $https . "://api.ipstack.com/" . $ip . "/?access_key=" . $key;
        try {
            $response = Remote::get($url);
            $ipdata = $response->code() === 200 ?
                @json_decode($response->content(), true) :
                null;
        } catch (\Exception $e) {
            $ipdata = [
                'ip' => $ip,
                'hostname' => $ip,
            ];
        }

        unset($ipdata['ip']); // remove the plain ip
        unset($ipdata['hostname']); // remove the plain host
        $cache->set($iphash, $ipdata, intval($this->option('ipstack_expire')));

        return $ipdata;
    }

    /** @var Utm */
    private static $singleton;

    /**
     * @param array $options
     * @return Utm
     */
    public static function singleton(array $options = [])
    {
        if (!self::$singleton) {
            self::$singleton = new self($options);
        }

        return self::$singleton;
    }

    private function sanitize(array $params)
    {
        $params = array_map(fn ($param) => \SQLite3::escapeString(strip_tags($param ?? '')), $params);
        return array_filter($params, fn ($param) => !empty($param));
    }

    private function ratelimit(string $iphash): bool
    {
        if ($this->option('ratelimit_enabled') !== true) {
            return true;
        }

        $cache = kirby()->cache('bnomei.utm.ratelimit');
        $key = $iphash;
        $limit = $cache->get($key);

        // none yet or time passed
        if (!$limit ||
            $limit['time'] + $this->option('ratelimit_expire') * 60 < time()) {
            $cache->set($key, [
                'time' => time(),
                'trials' => 1,
            ]);
            return true;
        }

        // below trial limit
        if ($limit['trials'] < $this->option('ratelimit_trials')) {
            $cache->set($key, [
                'time' => time(),
                'trials' => $limit['trials'] + 1,
            ], intval($this->option('ratelimit_expire')));
            return true;
        }

        return false; // limit reached
    }

    public static function sqliteDateRange(int $begin = 7, int $end = 0, string $column = 'visited_at'): string
    {
        return " $column >= datetime('now', '-$begin days', 'localtime') AND $column <= datetime('now', '-$end days', 'localtime')";
    }

    public static function percentChange($recent, $compare): int
    {
        return $compare > 0 ? intval(round($recent / $compare * 100.0 - 100.0)) : intval(round($recent * 100.0 - 100.0));
    }
}