diff --git a/engine/logging.php b/engine/logging.php index 4d42603..d81100b 100644 --- a/engine/logging.php +++ b/engine/logging.php @@ -230,11 +230,11 @@ class DatabaseLogger extends Logger { } } -function getPHPErrorName(int $errno): string { +function getPHPErrorName(int $errno): ?string { static $errors = null; if (is_null($errors)) $errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true)); - return $errors[$errno]; + return $errors[$errno] ?? null; } function strVarDump($var, bool $print_r = false): string { diff --git a/engine/request.php b/engine/request.php index b4d160d..a610fde 100644 --- a/engine/request.php +++ b/engine/request.php @@ -223,4 +223,15 @@ function csrf_get(string $key): string { function csrf_check(string $key) { if (csrf_get($key) != ($_REQUEST['token'] ?? '')) forbidden('invalid token'); +} + +function get_page(int $per_page, ?int $count = null): array { + list($page) = input('i:page'); + $pages = $count !== null ? ceil($count / $per_page) : null; + if ($pages !== null && $page > $pages) + $page = $pages; + if ($page < 1) + $page = 1; + $offset = $per_page * ($page-1); + return [$page, $pages, $offset]; } \ No newline at end of file diff --git a/engine/router.php b/engine/router.php index 3dd1924..c1ec2ef 100644 --- a/engine/router.php +++ b/engine/router.php @@ -1,6 +1,6 @@ SkinStringModificationType::URL, 'jsonencoded' => SkinStringModificationType::JSON, 'addslashes' => SkinStringModificationType::ADDSLASHES, + 'nl2br' => SkinStringModificationType::NL2BR, default => SkinStringModificationType::HTML }; } else { @@ -222,6 +223,69 @@ class SkinContext { return '
'.$buf.'
'; } + function pagenav(int $page, int $pages, string $link_template, ?array $opts = null) { + if ($opts === null) { + $count = 0; + } else { + $opts = array_merge([ + 'count' => 0, + ], $opts); + $count = $opts['count']; + } + + $min_page = max(1, $page-2); + $max_page = min($pages, $page+2); + + $pages_html = ''; + $base_class = 'pn-button no-hover no-select no-drag is-page'; + for ($p = $min_page; $p <= $max_page; $p++) { + $class = $base_class; + if ($p == $page) { + $class .= ' is-page-cur'; + } + + $pages_html .= ''.$p.''; + } + + if ($min_page > 2) { + $pages_html = '
 
'.$pages_html; + } + if ($min_page > 1) { + $pages_html = '1'.$pages_html; + } + + if ($max_page < $pages-1) { + $pages_html .= '
 
'; + } + if ($max_page < $pages) { + $pages_html .= ''.$pages.''; + } + + $pn_class = 'pn'; + if ($pages < 2) { + $pn_class .= ' no-nav'; + if (!$count) { + $pn_class .= ' no-results'; + } + } + + $html = << +
+ {$pages_html} +
+ +HTML; + + return $html; + } + + protected static function _page_nav_get_link($page, $link_template) { + return is_callable($link_template) + ? $link_template($page) + : str_replace('{page}', $page, $link_template); + } + protected function _if_condition($condition, $callback, ...$args) { if (is_string($condition) || $condition instanceof Stringable) $condition = (string)$condition !== ''; @@ -339,6 +403,7 @@ enum SkinStringModificationType { case HTML; case JSON; case ADDSLASHES; + case NL2BR; } class SkinString implements Stringable { @@ -353,6 +418,7 @@ class SkinString implements Stringable { SkinStringModificationType::URL => urlencode($this->string), SkinStringModificationType::JSON => jsonEncode($this->string), SkinStringModificationType::ADDSLASHES => addslashes($this->string), + SkinStringModificationType::NL2BR => nl2br(htmlescape($this->string)), default => $this->string, }; } diff --git a/functions.php b/functions.php index cb9e007..1adc648 100644 --- a/functions.php +++ b/functions.php @@ -367,4 +367,46 @@ function hl_matched(string $s, string|Stringable|SkinString|array|null $keywords } return $buf; +} + +function format_time($ts, array $opts = array()) { + $default_opts = [ + 'date_only' => false, + 'day_of_week' => false, + 'seconds' => false, + 'short_months' => false, + 'skip_today_date' => false, + 'skip_old_time' => false, + 'at' => null, + ]; + $opts = array_merge($default_opts, $opts); + + $is_today = date('Ymd') == date('Ymd', $ts); + $date = ''; + $skip_old_time = false; + + if (!$is_today || !$opts['skip_today_date']) { + $y = (int)date('Y', $ts); + $date .= date('j '.(!$opts['short_months'] ? 'F' : 'M'), $ts); + if ($y != (int)date('Y')) { + $date .= ' ' . $y; + if ($opts['skip_old_time']) { + $skip_old_time = true; + } + } + if ($opts['day_of_week']) { + $date .= ', ' . date('l', $ts); + } + } + + if (!$opts['date_only'] && !$skip_old_time) { + if ($date != '') { + $date .= $opts['at'] ?? ' at '; + } + $date .= date('H:i', $ts); + if ($opts['seconds']) + $date .= date(':s', $ts); + } + + return $date; } \ No newline at end of file diff --git a/handler/AdminHandler.php b/handler/AdminHandler.php index 06e27c9..2112e08 100644 --- a/handler/AdminHandler.php +++ b/handler/AdminHandler.php @@ -42,6 +42,112 @@ class AdminHandler extends request_handler { redirect('/admin/login/', HTTPCode::Found); } + function GET_errors() { + list($ip, $query, $url_query, $file_query, $line_query, $per_page) + = input('i:ip, query, url_query, file_query, i:line_query, i:per_page'); + + if (!$per_page) + $per_page = 10; + $db = DB(); + + $query = trim($query ?? ''); + $url_query = trim($url_query ?? ''); + + $sql_where = []; + if ($ip) { + $sql_where[] = "ip='".$db->escape($ip)."'"; + } + if ($query) { + $sql_where[] = "text LIKE '%".$db->escape($query)."%'"; + } + if ($url_query) { + $sql_where[] = "url LIKE '%".$db->escape($url_query)."%'"; + } + if ($file_query) { + $sql_where[] = "file LIKE '%".$db->escape($file_query)."%'"; + } + if ($line_query) { + $sql_where[] = "line='".$db->escape($line_query)."'"; + } + + if (!empty($sql_where)) { + $sql_where = " WHERE ".implode(' AND ', $sql_where)." "; + } else { + $sql_where = ''; + } + + $count = (int)$db->result($db->query("SELECT COUNT(*) FROM backend_errors".$sql_where)); + list($page, $pages, $offset) = get_page($per_page, $count); + + $q = $db->query("SELECT *, INET_NTOA(ip) ip_s FROM backend_errors $sql_where ORDER BY id DESC LIMIT $offset, $per_page"); + + $list = []; + + while ($row = $db->fetch($q)) { + $row['date'] = format_time($row['ts'], [ + 'seconds' => true, + 'short_months' => true, + ]); + $row['full_url'] = !str_starts_with($row['url'], 'https://') ? 'https://'.$row['url'] : $row['url']; + $error_name = getPHPErrorName((int)$row['errno']); + if (!is_null($error_name)) + $row['errtype'] = $error_name; + $list[] = $row; + } + + // url for pageNav + $url = '/admin/errors/'; + $url_params = []; + if ($ip) { + $url_params['ip'] = $ip; + } + if ($query) { + $url_params['query'] = $query; + } + if ($url_query) { + $url_params['url_query'] = $url_query; + } + if ($file_query) { + $url_params['file_query'] = $file_query; + } + if ($line_query) { + $url_params['line_query'] = $line_query; + } + + if (!empty($url_params)) { + $url .= '?'.http_build_query($url_params).'&'; + } else { + $url .= '?'; + } + + $vars = [ + 'list' => $list, + 'count' => $count, + 'pn_page' => $page, + 'pn_pages' => $pages, + 'url' => $url, + ]; + + if ($ip) { + $vars += [ + 'ip_filter' => ulong2ip($ip), + 'ip' => $ip + ]; + } + + $query_var_names = ['query', 'url_query', 'file_query', 'line_query']; + foreach ($query_var_names as $query_var_name) { + if ($$query_var_name) { + $vars += [$query_var_name => $$query_var_name]; + } + } + + set_skin_opts(['wide' => true]); + set_title('$admin_errors'); + render('admin/errors', + ...$vars); + } + function GET_uploads() { list($error) = input('error'); $uploads = uploads::getAllUploads(); diff --git a/htdocs/scss/app/common.scss b/htdocs/scss/app/common.scss index d721ed5..2674462 100644 --- a/htdocs/scss/app/common.scss +++ b/htdocs/scss/app/common.scss @@ -32,6 +32,9 @@ body { user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */ } +.no-drag { + -webkit-user-drag: none; +} .base-width { max-width: $base-width; diff --git a/htdocs/scss/app/pagenav.scss b/htdocs/scss/app/pagenav.scss new file mode 100644 index 0000000..48ff611 --- /dev/null +++ b/htdocs/scss/app/pagenav.scss @@ -0,0 +1,83 @@ +.pn { + text-align: center; +} + +.pn-button { + border: 1px $border-color solid; + background-color: $bg; + color: $fg; + display: block; + font-size: 14px; + text-align: center; + cursor: pointer; + padding: 7px 12px; + float: left; + transition: 0.075s border-color linear, 0.075s background-color linear, 0.075s color linear; +} +.pn-button:hover, +.pn-button:active { + border-color: $pn-button-border; + box-shadow: 0 1px 1px $pn-button-shadow; +} +.pn-button:active { + background-color: $pn-button-active; +} + +.pn-button.is-page, +.pn-button-sep { + margin-right: -1px; + position: relative; +} +a.pn-button.is-page { + text-decoration: none; +} +.pn-button.is-page-cur { + color: $pn-button-current-text-color; + background-color: $pn-button-current; + border-color: $pn-button-border; + z-index: 6; +} +.pn-button.is-page:hover { + z-index: 5; +} +.pn-button.is-page:first-child { + border-radius: 3px 0 0 3px; +} +.pn-button.is-page:last-child { + border-radius: 0 3px 3px 0; +} + +.pn-count { + float: right; + color: $grey; + font-size: 14px; + padding: 8px 0; +} + + +.pn.no-nav.no-results { + display: none; +} +.pn.no-nav > .pn-show-more { + display: none; +} +.pn.no-nav > .pn-buttons > div:not(.pn-count) { + display: none; +} +.pn.no-nav .pn-count { + float: none; + text-align: center; +} +.pn-buttons { + padding-top: $base-padding; + display: inline-block; +} + +.pn-button-sep { + font-size: 14px; + float: left; + width: 5px; + border: 1px $pn-button-separator-border solid; + background: $pn-button-separator-background; + padding: 7px 1px; +} diff --git a/htdocs/scss/bundle_admin.scss b/htdocs/scss/bundle_admin.scss index 06808d0..29c9c56 100644 --- a/htdocs/scss/bundle_admin.scss +++ b/htdocs/scss/bundle_admin.scss @@ -1,3 +1,59 @@ .admin-page { line-height: 155%; } + +table.admin-error-log { + margin-top: 10px; + width: 100%; + border-collapse: collapse; + border: 1px $grey solid; + font-size: 11px; + table-layout: fixed; +} +table.admin-error-log button { + font-size: 11px; +} +table.admin-error-log td, +table.admin-error-log th { + padding: 5px; + border: 1px $grey solid; + text-align: left; + vertical-align: top; +} +table.admin-error-log th { + overflow: hidden; + text-overflow: ellipsis; +} +table.admin-error-log td.admin-error-log-ms { + font-family: monospace; + overflow-x: auto; +} +table.admin-error-log td.admin-error-log-wrap { + word-break: break-all; +} +table.admin-error-log td.admin-error-log-nopadding { + padding: 0; +} +table.admin-error-log .admin-error-log-scrollable-wrap { + overflow-x: auto; + max-width: 100%; + height: 100%; + padding: 5px; +} +.admin-error-log-num { + padding: 1px 2px; + background: $light-grey; + color: $bg; +} +.admin-error-log-num > a { + color: $bg; +} +.admin-error-log-num > a:hover { + color: $link-color; +} +.admin-error-log-stacktrace-wrap { + padding-top: 5px; +} +.admin-error-log-link { + word-wrap: break-word; +} diff --git a/htdocs/scss/bundle_common.scss b/htdocs/scss/bundle_common.scss index d964461..18a7bbb 100644 --- a/htdocs/scss/bundle_common.scss +++ b/htdocs/scss/bundle_common.scss @@ -7,6 +7,7 @@ @import "./app/files"; @import "./hljs/github.scss"; @import "./app/widgets"; +@import "./app/pagenav"; @media screen and (max-width: 880px) { @import "./app/mobile"; diff --git a/htdocs/scss/colors/dark.scss b/htdocs/scss/colors/dark.scss index a38987f..b41ff0e 100644 --- a/htdocs/scss/colors/dark.scss +++ b/htdocs/scss/colors/dark.scss @@ -36,6 +36,15 @@ $error-block-fg: $fg; $success-block-bg: #2a4b2d; $success-block-fg: $fg; +$pn-button-border: $dark-grey; +$pn-button-shadow: rgba(0, 0, 0, 0.1); +$pn-button-active: $dark-grey; +$pn-button-separator-border: #e0e0e0; +$pn-button-separator-background: #f0f0f0; +$pn-button-current: #a7d2fd; +$pn-button-border: #92bae2; +$pn-button-current-text-color: $bg; + $head-items-separator: #5e6264; // colors from https://github.com/Kelbster/highlightjs-material-dark-theme/blob/master/css/materialdark.css diff --git a/htdocs/scss/colors/light.scss b/htdocs/scss/colors/light.scss index 46a57d8..2238144 100644 --- a/htdocs/scss/colors/light.scss +++ b/htdocs/scss/colors/light.scss @@ -36,6 +36,15 @@ $error-block-fg: #d13d3d; $success-block-bg: #eff5f0; $success-block-fg: #2a6f34; +$pn-button-border: #d0d0d0; +$pn-button-shadow: #f0f0f0; +$pn-button-active: #f4f4f4; +$pn-button-separator-border: #e0e0e0; +$pn-button-separator-background: #f0f0f0; +$pn-button-current: #a7d2fd; +$pn-button-border: #92bae2; +$pn-button-current-text-color: $fg; + $head-items-separator: #d0d0d0; // github.com style (c) Vasily Polovnyov diff --git a/routes.php b/routes.php index aa8e70b..df86f83 100644 --- a/routes.php +++ b/routes.php @@ -32,7 +32,7 @@ return (function() { 'articles/write/' => 'post_add', 'articles/([a-z0-9-]+)/{delete,edit}/' => 'post_${1} short_name=$(1)', 'admin/markdown-preview.ajax' => 'ajax_md_preview', - 'admin/{uploads}/' => '${1}', + 'admin/{uploads,errors}/' => '${1}', 'admin/uploads/{edit_note,delete}/(\d+)/' => 'upload_${1} id=$(1)' ] ]; diff --git a/skin/admin.phps b/skin/admin.phps index 54652e5..8a8d811 100644 --- a/skin/admin.phps +++ b/skin/admin.phps @@ -52,6 +52,7 @@ function index($ctx, $admin_login) { Authorized as {$admin_login} | Sign out
Uploads
+ {$ctx->lang('admin_errors')}
HTML; } @@ -402,6 +403,7 @@ HTML; function markdownPreview($ctx, $unsafe_html, $title) { return <<
{$ctx->if_true($title, '

'.$title.'

')}
{$unsafe_html}
@@ -417,5 +419,129 @@ return << $ctx->lang('admin_books')], ])} +HTML; +} + +function errors($ctx, + array $list, + int $count, + int $pn_page, + int $pn_pages, + string $url, + ?string $ip_filter = null, + ?int $ip = null, + ?string $query = null, + ?string $url_query = null, + ?string $file_query = null, + ?string $line_query = null) { +return <<bc([ + ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'], + ['text' => $ctx->lang('admin_errors')], +])} + +
+ {$ctx->if_true($ip, fn() => '')} + + + + + +
+ +{$ctx->if_then_else(!empty($list), + fn() => $ctx->errors_table($list), + fn() => '
Error log is empty.
')} + +{$ctx->pagenav($pn_page, $pn_pages, $url.'page={page}')} +HTML; +} + +function errors_table($ctx, + array $list) { +return << + + + Time + Source + Error + + + + {$ctx->for_each($list, fn($item) => $ctx->errors_table_item( + date: $item['date'], + is_cli: (bool)$item['is_cli'], + is_custom: (bool)$item['custom'], + user_agent: $item['ua'], + ip: (int)$item['ip'], + ip_s: $item['ip_s'], + full_url: $item['full_url'], + url: $item['url'], + file: $item['file'], + line: (int)$item['line'], + admin_id: (int)$item['admin_id'], + nl2br_text: $item['text'], + num: (int)$item['num'], + errtype: $item['errtype'] ?? null, + time: $item['time'], + stacktrace: $item['stacktrace'], + item_id: (int)$item['id'] + ))} + + +HTML; +} + +function errors_table_item($ctx, + int $item_id, + string $date, + bool $is_cli, + bool $is_custom, + string $user_agent, + int $ip, + string $ip_s, + string $full_url, + string $url, + string $file, + int $line, + int $admin_id, + string $nl2br_text, + int $num, + ?string $errtype, + string $time, + string $stacktrace) { +return << + + {$date} + + + {$ctx->if_then_else(!$is_cli, + fn() => ''.$ip_s.' '.$url.'
'.$user_agent, + fn() => 'cmd')} + + + + {$ctx->if_true($admin_id, + fn() => 'admin='.$admin_id.'')} + {$ctx->if_then_else($is_custom, + fn() => ''.$num.', '.$time.' '.$file.':'.$line.'
' + .''.$errtype.' '.$nl2br_text, + fn() => ''.$num.', '.$time.' '.$nl2br_text)} + + {$ctx->if_true($stacktrace, + fn() => $ctx->errors_table_item_stacktrace($item_id, $stacktrace))} + + +HTML; +} + +function errors_table_item_stacktrace($ctx, $item_id, $nl2br_stacktrace) { +return << + Show/hide stacktrace + +
HTML; } \ No newline at end of file diff --git a/skin/main.phps b/skin/main.phps index 4e49480..c6a2a67 100644 --- a/skin/main.phps +++ b/skin/main.phps @@ -85,7 +85,7 @@ $title = $pt->title; return <<if_true($ctx->year > $year, $ctx->articlesIndexYearLine, $year, $index === 0, $selected_lang->value)} - + 1 {$date} diff --git a/strings/main.yaml b/strings/main.yaml index 5734694..c8d453f 100644 --- a/strings/main.yaml +++ b/strings/main.yaml @@ -96,6 +96,7 @@ admin_title: Admin admin_password: Password admin_login: Login admin_books: Books +admin_errors: Errors # /files files: Files @@ -125,4 +126,13 @@ files_search_results_count: - "%s results" - "%s results" - "No results" -files_show_more: Show more \ No newline at end of file +files_show_more: Show more + +pages: 'Страницы' +pn_show_more: 'Показать ещё' +pn_prev: 'назад' +pn_next: 'вперёд' +pn_first: 'в начало' +pn_last: 'в конец' +#pn_goto: 'Перейти' +#pn_goto_n: '№ стр.' \ No newline at end of file