Passed
Push — develop ( 4713f8...5b295b )
by Paul
13:34
created

Console::canLogEntry()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3.072

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 3
nop 2
dl 0
loc 7
ccs 4
cts 5
cp 0.8
crap 3.072
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace GeminiLabs\SiteReviews\Modules;
4
5
use GeminiLabs\SiteReviews\Helper;
6
use GeminiLabs\SiteReviews\Helpers\Arr;
7
use GeminiLabs\SiteReviews\Helpers\Cast;
8
use GeminiLabs\SiteReviews\Helpers\Str;
9
10
/**
11
 * @method static debug($message, $context = [])
12
 * @method static info($message, $context = [])
13
 * @method static notice($message, $context = [])
14
 * @method static warning($message, $context = [])
15
 * @method static error($message, $context = [])
16
 * @method static critical($message, $context = [])
17
 * @method static alert($message, $context = [])
18
 * @method static emergency($message, $context = [])
19
 */
20
class Console
21
{
22
    public const DEBUG = 0;      // Detailed debug information
23
    public const INFO = 1;       // Interesting events
24
    public const NOTICE = 2;     // Normal but significant events
25
    public const WARNING = 4;    // Exceptional occurrences that are not errors
26
    public const ERROR = 8;      // Runtime errors that do not require immediate action
27
    public const CRITICAL = 16;  // Critical conditions
28
    public const ALERT = 32;     // Action must be taken immediately
29
    public const EMERGENCY = 64; // System is unusable
30
31
    public const LOG_LEVEL_KEY = 'glsr_console_level';
32
    public const LOG_ONCE_KEY = 'glsr_log_once';
33
34
    protected $file;
35
    protected $log;
36
37 41
    public function __construct()
38
    {
39 41
        $this->setLogFile();
40 41
        $this->reset();
41
    }
42
43 41
    public function __call(string $method, $args)
44
    {
45 41
        $constant = strtoupper($method);
46 41
        $instance = new \ReflectionClass($this);
47 41
        if ($instance->hasConstant($constant)) {
48 41
            $args = Arr::prepend($args, $instance->getConstant($constant));
49 41
            return call_user_func_array([$this, 'log'], array_slice($args, 0, 3));
50
        }
51
        throw new \BadMethodCallException("Method [$method] does not exist.");
52
    }
53
54
    public function __toString(): string
55
    {
56
        return $this->get();
57
    }
58
59
    public function clear(): void
60
    {
61
        $this->log = '';
62
        file_put_contents($this->file, $this->log);
63
    }
64
65
    public function get(): string
66
    {
67
        return esc_html(
68
            Helper::ifEmpty($this->log, _x('Console is empty', 'admin-text', 'site-reviews'))
69
        );
70
    }
71
72
    public function getRaw(): string
73
    {
74
        return htmlspecialchars_decode($this->get(), ENT_QUOTES);
75
    }
76
77 41
    public function getLevel(): int
78
    {
79 41
        $level = Cast::toInt(get_option(static::LOG_LEVEL_KEY, static::INFO));
80 41
        $levels = [
81 41
            static::ALERT, static::CRITICAL, static::DEBUG, static::EMERGENCY,
82 41
            static::ERROR, static::INFO, static::NOTICE, static::WARNING,
83 41
        ];
84 41
        if (in_array($level, $levels)) {
85 41
            return $level;
86
        }
87
        return static::INFO;
88
    }
89
90 41
    public function getLevels(): array
91
    {
92 41
        $constants = (new \ReflectionClass(__CLASS__))->getConstants();
93 41
        return array_map('strtolower', array_flip($constants));
94
    }
95
96
    public function humanLevel(): string
97
    {
98
        $level = $this->getLevel();
99
        return sprintf('%s (%d)', strtoupper(Arr::get($this->getLevels(), $level, 'unknown')), $level);
100
    }
101
102
    public function humanSize(): string
103
    {
104
        return Str::replaceLast(' B', ' bytes', Cast::toString(size_format($this->size())));
105
    }
106
107
    /**
108
     * @param mixed $message
109
     *
110
     * @return static
111
     */
112 41
    public function log(int $level, $message, array $context = [], string $backtraceLine = '')
113
    {
114 41
        if (empty($backtraceLine)) {
115 41
            $backtraceLine = glsr(Backtrace::class)->line();
116
        }
117 41
        if (!$this->canLogEntry($level, $backtraceLine)) {
118 38
            return $this;
119
        }
120 4
        if (is_string($message)) {
121 4
            $message = $this->interpolate($message, $context);
122
        }
123 4
        $backtraceLine = glsr(Backtrace::class)->normalizeLine($backtraceLine);
124 4
        $levelName = Arr::get($this->getLevels(), $level, 'unknown');
125 4
        $entry = $this->buildLogEntry(
126 4
            $levelName,
127 4
            glsr(Dump::class)->dump($message),
128 4
            $backtraceLine
129 4
        );
130 4
        file_put_contents($this->file, $entry.PHP_EOL, FILE_APPEND | LOCK_EX);
131 4
        apply_filters('console', $message, $levelName, $backtraceLine); // Show in Blackbar plugin if installed
132 4
        $this->reset();
133 4
        return $this;
134
    }
135
136
    public function logOnce(): void
137
    {
138
        $once = glsr()->retrieveAs('array', static::LOG_ONCE_KEY);
139
        $levels = $this->getLevels();
140
        foreach ($once as $entry) {
141
            $levelName = Arr::get($entry, 'level');
142
            if (in_array($levelName, $levels)) {
143
                $level = Arr::get(array_flip($levels), $levelName);
144
                $message = Arr::get($entry, 'message');
145
                $backtraceLine = Arr::get($entry, 'backtrace');
146
                $this->log($level, $message, [], $backtraceLine);
147
            }
148
        }
149
        glsr()->store(static::LOG_ONCE_KEY, []);
150
    }
151
152
    /**
153
     * @param mixed $data
154
     */
155
    public function once(string $levelName, string $handle, $data): void
156
    {
157
        $once = glsr()->retrieveAs('array', static::LOG_ONCE_KEY);
158
        $filtered = array_filter($once, function ($entry) use ($levelName, $handle) {
159
            return Arr::get($entry, 'level') === $levelName
160
                && Arr::get($entry, 'handle') === $handle;
161
        });
162
        if (empty($filtered)) {
163
            $once[] = [
164
                'backtrace' => glsr(Backtrace::class)->lineFromData($data),
165
                'handle' => $handle,
166
                'level' => $levelName,
167
                'message' => '[RECURRING] '.$this->getMessageFromData($data),
168
            ];
169
            glsr()->store(static::LOG_ONCE_KEY, $once);
170
        }
171
    }
172
173 41
    public function size(): int
174
    {
175 41
        return file_exists($this->file)
176 41
            ? filesize($this->file)
177 41
            : 0;
178
    }
179
180 4
    protected function buildLogEntry(string $levelName, string $message, string $backtraceLine = ''): string
181
    {
182 4
        return sprintf('[%s] %s [%s] %s',
183 4
            current_time('mysql'),
184 4
            strtoupper($levelName),
185 4
            $backtraceLine,
186 4
            esc_html($message)
187 4
        );
188
    }
189
190 41
    protected function canLogEntry(int $level, string $backtraceLine): bool
191
    {
192 41
        $levelExists = array_key_exists($level, $this->getLevels());
193 41
        if (!Str::contains($backtraceLine, [glsr()->path(), 'GeminiLabs\SiteReviews'])) {
194
            return $levelExists; // ignore level restriction if triggered outside of the plugin
195
        }
196 41
        return $levelExists && $level >= $this->getLevel();
197
    }
198
199
    /**
200
     * @param mixed|\Throwable $data
201
     */
202
    protected function getMessageFromData($data): string
203
    {
204
        return ($data instanceof \Throwable)
205
            ? $this->normalizeThrowableMessage($data->getMessage())
206
            : glsr(Dump::class)->dump($data);
207
    }
208
209
    /**
210
     * Interpolates context values into the message placeholders.
211
     */
212 4
    protected function interpolate(string $message, array $context = []): string
213
    {
214 4
        $context = Arr::consolidate($context);
215 4
        if (empty($context)) {
216 4
            return $message;
217
        }
218
        $replace = [];
219
        foreach ($context as $key => $value) {
220
            $replace['{'.$key.'}'] = $this->normalizeValue($value);
221
        }
222
        return strtr($message, $replace);
223
    }
224
225
    protected function normalizeThrowableMessage(string $message): string
226
    {
227
        $calledIn = strpos($message, ', called in');
228
        return false !== $calledIn
229
            ? substr($message, 0, $calledIn)
230
            : $message;
231
    }
232
233
    /**
234
     * @param mixed $value
235
     */
236
    protected function normalizeValue($value): string
237
    {
238
        if ($value instanceof \DateTime) {
239
            $value = $value->format('Y-m-d H:i:s');
240
        } elseif (!is_scalar($value)) {
241
            $value = wp_json_encode($value);
242
        }
243
        return Cast::toString($value);
244
    }
245
246 41
    protected function reset(): void
247
    {
248 41
        if ($this->size() <= wp_convert_hr_to_bytes('512kb')) {
249 41
            return;
250
        }
251
        $this->clear();
252
        file_put_contents($this->file,
253
            $this->buildLogEntry(static::NOTICE,
254
                _x('Console was automatically cleared (512KB maximum size)', 'admin-text', 'site-reviews')
255
            )
256
        );
257
    }
258
259 41
    protected function setLogFile(): void
260
    {
261 41
        $uploads = wp_upload_dir();
262 41
        if (!file_exists($uploads['basedir'])) {
263
            $uploads = wp_upload_dir(null, true, true); // maybe the site has been moved, so refresh the cached uploads path
264
        }
265 41
        $base = trailingslashit($uploads['basedir'].'/'.glsr()->id);
266 41
        $this->file = $base.'logs/'.sanitize_file_name('console-'.Str::hash(glsr()->id).'.log');
267 41
        $files = [
268 41
            $base.'index.php' => '<?php',
269 41
            $base.'logs/.htaccess' => 'deny from all',
270 41
            $base.'logs/index.php' => '<?php',
271 41
            $this->file => '',
272 41
        ];
273 41
        foreach ($files as $file => $contents) {
274 41
            if (wp_mkdir_p(dirname($file)) && !file_exists($file)) {
275 1
                file_put_contents($file, $contents);
276
            }
277
        }
278 41
        $this->log = file_get_contents($this->file);
279
    }
280
}
281