1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2019 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16namespace Fisharebest\Webtrees;
17
18use Fisharebest\Webtrees\Controller\PageController;
19use Fisharebest\Webtrees\Functions\FunctionsEdit;
20use PDO;
21
22/**
23 * Defined in session.php
24 *
25 * @global Tree $WT_TREE
26 */
27global $WT_TREE;
28
29define('WT_SCRIPT_NAME', 'admin_site_logs.php');
30require './includes/session.php';
31
32$controller = new PageController;
33$controller
34    ->restrictAccess(Auth::isManager($WT_TREE))
35    ->setPageTitle(I18N::translate('Website logs'));
36
37$earliest = Database::prepare("SELECT IFNULL(DATE(MIN(log_time)), CURDATE()) FROM `##log`")->execute(array())->fetchOne();
38$latest   = Database::prepare("SELECT IFNULL(DATE(MAX(log_time)), CURDATE()) FROM `##log`")->execute(array())->fetchOne();
39
40// Filtering
41$action = Filter::get('action');
42$from   = Filter::get('from', '\d\d\d\d-\d\d-\d\d', $earliest);
43$to     = Filter::get('to', '\d\d\d\d-\d\d-\d\d', $latest);
44$type   = Filter::get('type', 'auth|change|config|debug|edit|error|media|search');
45$text   = Filter::get('text');
46$ip     = Filter::get('ip');
47$user   = Filter::get('user');
48$search = Filter::get('search');
49$search = isset($search['value']) ? $search['value'] : null;
50
51if (Auth::isAdmin()) {
52    // Administrators can see all logs
53    $gedc = Filter::get('gedc');
54} else {
55    // Managers can only see logs relating to this gedcom
56    $gedc = $WT_TREE->getName();
57}
58
59$sql_select =
60    "SELECT SQL_CALC_FOUND_ROWS log_id, log_time, log_type, log_message, ip_address, IFNULL(user_name, '<none>') AS user_name, IFNULL(gedcom_name, '<none>') AS gedcom_name" .
61    " FROM `##log`" .
62    " LEFT JOIN `##user` USING (user_id)" . // user may be deleted
63    " LEFT JOIN `##gedcom` USING (gedcom_id)"; // gedcom may be deleted
64
65$where = " WHERE 1";
66$args  = array();
67if ($search) {
68    $where .= " AND log_message LIKE CONCAT('%', :search, '%')";
69    $args['search'] = $search;
70}
71if ($from) {
72    $where .= " AND log_time >= :from";
73    $args['from'] = $from;
74}
75if ($to) {
76    $where .= " AND log_time < TIMESTAMPADD(DAY, 1 , :to)"; // before end of the day
77    $args['to'] = $to;
78}
79if ($type) {
80    $where .= " AND log_type = :type";
81    $args['type'] = $type;
82}
83if ($text) {
84    $where .= " AND log_message LIKE CONCAT('%', :text, '%')";
85    $args['text'] = $text;
86}
87if ($ip) {
88    $where .= " AND ip_address LIKE CONCAT('%', :ip, '%')";
89    $args['ip'] = $ip;
90}
91if ($user) {
92    $where .= " AND user_name LIKE CONCAT('%', :user, '%')";
93    $args['user'] = $user;
94}
95if ($gedc) {
96    $where .= " AND gedcom_name LIKE CONCAT('%', :gedc, '%')";
97    $args['gedc'] = $gedc;
98}
99
100switch ($action) {
101    case 'delete':
102        $sql_delete =
103        "DELETE `##log` FROM `##log`" .
104        " LEFT JOIN `##user` USING (user_id)" . // user may be deleted
105        " LEFT JOIN `##gedcom` USING (gedcom_id)"; // gedcom may be deleted
106
107        Database::prepare($sql_delete . $where)->execute($args);
108        break;
109
110    case 'export':
111        header('Content-Type: text/csv');
112        header('Content-Disposition: attachment; filename="webtrees-logs.csv"');
113        $rows = Database::prepare($sql_select . $where . ' ORDER BY log_id')->execute($args)->fetchAll();
114        foreach ($rows as $row) {
115            echo
116            '"', $row->log_time, '",',
117            '"', $row->log_type, '",',
118            '"', str_replace('"', '""', $row->log_message), '",',
119            '"', $row->ip_address, '",',
120            '"', str_replace('"', '""', $row->user_name), '",',
121            '"', str_replace('"', '""', $row->gedcom_name), '"',
122            "\n";
123        }
124
125        return;
126    case 'load_json':
127        $start  = Filter::getInteger('start');
128        $length = Filter::getInteger('length');
129        $order  = Filter::getArray('order');
130
131        if ($order) {
132            $order_by = " ORDER BY ";
133            foreach ($order as $key => $value) {
134                if ($key > 0) {
135                    $order_by .= ',';
136                }
137                // Datatables numbers columns 0, 1, 2
138                // MySQL numbers columns 1, 2, 3
139                switch ($value['dir']) {
140                    case 'asc':
141                        $order_by .= (1 + $value['column']) . " ASC ";
142                        break;
143                    case 'desc':
144                        $order_by .= (1 + $value['column']) . " DESC ";
145                    break;
146                }
147            }
148        } else {
149            $order_by = " ORDER BY 1 ASC";
150        }
151
152        if ($length) {
153            Auth::user()->setPreference('admin_site_log_page_size', $length);
154            $limit          = " LIMIT :limit OFFSET :offset";
155            $args['limit']  = $length;
156            $args['offset'] = $start;
157        } else {
158            $limit = "";
159        }
160
161        // This becomes a JSON list, not array, so need to fetch with numeric keys.
162        $data = Database::prepare($sql_select . $where . $order_by . $limit)->execute($args)->fetchAll(PDO::FETCH_NUM);
163        foreach ($data as &$datum) {
164            $datum[2] = Filter::escapeHtml($datum[2]);
165            $datum[3] = '<span dir="auto">' . Filter::escapeHtml($datum[3]) . '</span>';
166            $datum[4] = '<span dir="auto">' . Filter::escapeHtml($datum[4]) . '</span>';
167            $datum[5] = '<span dir="auto">' . Filter::escapeHtml($datum[5]) . '</span>';
168            $datum[6] = '<span dir="auto">' . Filter::escapeHtml($datum[6]) . '</span>';
169        }
170
171        // Total filtered/unfiltered rows
172        $recordsFiltered = (int) Database::prepare("SELECT FOUND_ROWS()")->fetchOne();
173        $recordsTotal    = (int) Database::prepare("SELECT COUNT(*) FROM `##log`")->fetchOne();
174
175        header('Content-type: application/json');
176        // See http://www.datatables.net/usage/server-side
177        echo json_encode(array(
178        'draw'            => Filter::getInteger('draw'),
179        'recordsTotal'    => $recordsTotal,
180        'recordsFiltered' => $recordsFiltered,
181        'data'            => $data,
182        ));
183
184        return;
185}
186
187$controller
188    ->pageHeader()
189    ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL)
190    ->addExternalJavascript(WT_DATATABLES_BOOTSTRAP_JS_URL)
191    ->addExternalJavascript(WT_MOMENT_JS_URL)
192    ->addExternalJavascript(WT_BOOTSTRAP_DATETIMEPICKER_JS_URL)
193    ->addInlineJavascript('
194		jQuery(".table-site-logs").dataTable( {
195			processing: true,
196			serverSide: true,
197			ajax: "' . WT_BASE_URL . WT_SCRIPT_NAME . '?action=load_json&from=' . $from . '&to=' . $to . '&type=' . $type . '&text=' . rawurlencode($text) . '&ip=' . rawurlencode($ip) . '&user=' . rawurlencode($user) . '&gedc=' . rawurlencode($gedc) . '",
198			' . I18N::datatablesI18N(array(10, 20, 50, 100, 500, 1000, -1)) . ',
199			sorting: [[ 0, "desc" ]],
200			pageLength: ' . Auth::user()->getPreference('admin_site_log_page_size', 10) . ',
201			columns: [
202			/* log_id      */ { visible: false },
203			/* Timestamp   */ { sort: 0 },
204			/* Type        */ { },
205			/* message     */ { },
206			/* IP address  */ { },
207			/* User        */ { },
208			/* Family tree */ { }
209			]
210		});
211		jQuery("#from,#to").parent("div").datetimepicker({
212			format: "YYYY-MM-DD",
213			minDate: "' . $earliest . '",
214			maxDate: "' . $latest . '",
215			locale: "' . WT_LOCALE . '",
216			icons: {
217				time: "fa fa-clock-o",
218				date: "fa fa-calendar",
219				up: "fa fa-arrow-up",
220				down: "fa fa-arrow-down",
221				previous: "fa fa-arrow-' . (I18N::direction() === 'rtl' ? 'right' : 'left') . '",
222				next: "fa fa-arrow-' . (I18N::direction() === 'rtl' ? 'left' : 'right') . '",
223				today: "fa fa-trash-o",
224				clear: "fa fa-trash-o"
225			}
226		});
227	');
228
229$users_array = array();
230foreach (User::all() as $tmp_user) {
231    $users_array[$tmp_user->getUserName()] = $tmp_user->getUserName();
232}
233
234?>
235<ol class="breadcrumb small">
236    <li><a href="admin.php"><?php echo I18N::translate('Control panel') ?></a></li>
237    <li class="active"><?php echo $controller->getPageTitle() ?></li>
238</ol>
239
240<h1><?php echo $controller->getPageTitle() ?></h1>
241
242<form class="form" name="logs">
243    <input type="hidden" name="action" value="show">
244
245    <div class="row">
246        <div class="form-group col-xs-6 col-sm-3">
247            <label for="from">
248                <?php echo /* I18N: label for the start of a date range (from x to y) */ I18N::translate('From') ?>
249            </label>
250            <div class="input-group date">
251                <input type="text" autocomplete="off" class="form-control" id="from" name="from" value="<?php echo Filter::escapeHtml($from) ?>" autocomplete="off">
252                <span class="input-group-addon"><span class="fa fa-calendar"></span></span>
253            </div>
254        </div>
255
256        <div class="form-group col-xs-6 col-sm-3">
257            <label for="to">
258                <?php echo /* I18N: label for the end of a date range (from x to y) */ I18N::translate('To') ?>
259            </label>
260            <div class="input-group date">
261                <input type="text" autocomplete="off" class="form-control" id="to" name="to" value="<?php echo Filter::escapeHtml($to) ?>" autocomplete="off">
262                <span class="input-group-addon"><span class="fa fa-calendar"></span></span>
263            </div>
264        </div>
265
266        <div class="form-group col-xs-6 col-sm-2">
267            <label for="type">
268                <?php echo I18N::translate('Type') ?>
269            </label>
270            <?php echo FunctionsEdit::selectEditControl('type', array('' => '', 'auth' => 'auth', 'config' => 'config', 'debug' => 'debug', 'edit' => 'edit', 'error' => 'error', 'media' => 'media', 'search' => 'search'), null, $type, 'class="form-control"') ?>
271        </div>
272
273        <div class="form-group col-xs-6 col-sm-4">
274            <label for="ip">
275                <?php echo I18N::translate('IP address') ?>
276            </label>
277            <input class="form-control" type="text" id="ip" name="ip" value="<?php echo Filter::escapeHtml($ip) ?>">
278        </div>
279    </div>
280
281    <div class="row">
282        <div class="form-group col-sm-4">
283            <label for="text">
284                <?php echo I18N::translate('Message') ?>
285            </label>
286            <input class="form-control" type="text" id="text" name="text" value="<?php echo Filter::escapeHtml($text) ?>">
287        </div>
288
289        <div class="form-group col-sm-4">
290            <label for="user">
291                <?php echo I18N::translate('User') ?>
292            </label>
293            <?php echo FunctionsEdit::selectEditControl('user', $users_array, '', $user, 'class="form-control"') ?>
294        </div>
295
296        <div class="form-group col-sm-4">
297            <label for="gedc">
298                <?php echo I18N::translate('Family tree') ?>
299            </label>
300            <?php echo FunctionsEdit::selectEditControl('gedc', Tree::getNameList(), '', $gedc, Auth::isAdmin() ? 'class="form-control"' : 'disabled class="form-control"') ?>
301        </div>
302    </div>
303
304    <div class="row text-center">
305        <button type="submit" class="btn btn-primary">
306            <?php echo /* I18N: A button label. */ I18N::translate('search') ?>
307        </button>
308
309        <button type="submit" class="btn btn-primary" onclick="document.logs.action.value='export';return true;" <?php echo $action === 'show' ? '' : 'disabled' ?>>
310            <?php echo /* I18N: A button label. */ I18N::translate('download') ?>
311        </button>
312
313        <button type="submit" class="btn btn-primary" onclick="if (confirm('<?php echo I18N::translate('Permanently delete these records?') ?>')) {document.logs.action.value='delete'; return true;} else {return false;}" <?php echo $action === 'show' ? '' : 'disabled' ?>>
314            <?php echo /* I18N: A button label. */ I18N::translate('delete') ?>
315        </button>
316    </div>
317</form>
318
319<?php if ($action): ?>
320<table class="table table-bordered table-condensed table-hover table-site-logs">
321    <caption class="sr-only">
322        <?php echo $controller->getPageTitle() ?>
323    </caption>
324    <thead>
325        <tr>
326            <th></th>
327            <th><?php echo I18N::translate('Timestamp') ?></th>
328            <th><?php echo I18N::translate('Type') ?></th>
329            <th><?php echo I18N::translate('Message') ?></th>
330            <th><?php echo I18N::translate('IP address') ?></th>
331            <th><?php echo I18N::translate('User') ?></th>
332            <th><?php echo I18N::translate('Family tree') ?></th>
333        </tr>
334    </thead>
335</table>
336<?php endif ?>
337