Skip to content

Commit bdaef85

Browse files
committed
Merge branch 'main' of https://github.com/d0ubIeU/phpMyFAQ
2 parents fdd837b + f8549f2 commit bdaef85

40 files changed

+1133
-305
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ This is a log of major user-visible changes in each phpMyFAQ release.
1111
- changed PHP requirement to PHP 8.4 or later (Thorsten)
1212
- added Symfony Router for frontend (Thorsten)
1313
- added API for glossary definitions (Thorsten)
14+
- added admin log CSV export feature (Thorsten)
15+
- improved audit and activity log with comprehensive security event tracking (Thorsten)
1416
- improved API errors with formatted RFC 7807 Problem Details JSON responses (Thorsten)
1517
- migrated codebase to use PHP 8.4 language features (Thorsten)
1618

composer.lock

Lines changed: 11 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

phpmyfaq/admin/assets/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
handleCreateReport,
2121
handleDeleteAdminLog,
2222
handleDeleteSessions,
23+
handleExportAdminLog,
2324
handleSessions,
2425
handleSessionsFilter,
2526
handleStatistics,
@@ -141,6 +142,7 @@ document.addEventListener('DOMContentLoaded', async (): Promise<void> => {
141142

142143
// Statistics
143144
handleDeleteAdminLog();
145+
handleExportAdminLog();
144146
handleStatistics();
145147
handleCreateReport();
146148
handleTruncateSearchTerms();

phpmyfaq/admin/assets/src/statistics/admin-log.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,57 @@ import { deleteAdminLog } from '../api';
1717
import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils';
1818
import { Response } from '../interfaces';
1919

20+
export const handleExportAdminLog = (): void => {
21+
const buttonExportAdminLog = document.getElementById('pmf-export-admin-log') as HTMLButtonElement | null;
22+
23+
if (buttonExportAdminLog) {
24+
buttonExportAdminLog.addEventListener('click', async (event: Event): Promise<void> => {
25+
event.preventDefault();
26+
const target = event.currentTarget as HTMLElement;
27+
const csrf = target.getAttribute('data-pmf-csrf');
28+
29+
if (!csrf) {
30+
pushErrorNotification('Missing CSRF token');
31+
return;
32+
}
33+
34+
try {
35+
const response = await fetch('/admin/api/statistics/admin-log/export', {
36+
method: 'POST',
37+
headers: {
38+
'Content-Type': 'application/json',
39+
},
40+
body: JSON.stringify({ csrf }),
41+
});
42+
43+
if (response.ok) {
44+
const blob = await response.blob();
45+
const url = window.URL.createObjectURL(blob);
46+
const contentDisposition = response.headers.get('Content-Disposition');
47+
const filename = contentDisposition
48+
? contentDisposition.split('filename=')[1]?.replace(/"/g, '')
49+
: 'admin-log-export.csv';
50+
51+
const a = document.createElement('a');
52+
a.href = url;
53+
a.download = filename;
54+
document.body.appendChild(a);
55+
a.click();
56+
document.body.removeChild(a);
57+
window.URL.revokeObjectURL(url);
58+
59+
pushNotification('Admin log exported successfully');
60+
} else {
61+
const errorData = await response.json();
62+
pushErrorNotification(errorData.error || 'Export failed');
63+
}
64+
} catch (error) {
65+
pushErrorNotification('Export error: ' + error);
66+
}
67+
});
68+
}
69+
};
70+
2071
export const handleDeleteAdminLog = (): void => {
2172
const buttonDeleteAdminLog = document.getElementById('pmf-delete-admin-log') as HTMLButtonElement | null;
2273

phpmyfaq/assets/templates/admin/statistics/admin-log.twig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
</h1>
99
<div class="btn-toolbar mb-2 mb-md-0">
1010
<div class="btn-group mr-2">
11+
<button class="btn btn-outline-success" id="pmf-export-admin-log" data-pmf-csrf="{{ csrfExportAdminLogToken }}">
12+
<i aria-hidden="true" class="bi bi-download"></i> {{ buttonExportAdminLog }}
13+
</button>
1114
<button class="btn btn-outline-danger" id="pmf-delete-admin-log" data-pmf-csrf="{{ csrfDeleteAdminLogToken }}">
1215
<i aria-hidden="true" class="bi bi-trash"></i> {{ buttonDeleteAdminLog }}
1316
</button>

phpmyfaq/assets/templates/default/search.twig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454

5555
{% if totalPages > 1 %}
5656
<p>
57-
<strong>{{ msgPage }}{{ currentPage }} {{ from }} {{ msgSearchResults }}</strong>
57+
<strong>{{ msgPage }}{{ currentPage }} {{ from }} {{ msgSearchResultsPagination }}</strong>
5858
</p>
5959
{% endif %}
6060

@@ -122,7 +122,7 @@
122122
<h4 class="fst-italic">{{ msgMostPopularSearches }}</h4>
123123
<div class="clearfix">
124124
{% for popular in mostPopularSearches %}
125-
<a class="btn btn-outline-primary m-1" href="?search={{ popular['searchterm'] }}">
125+
<a class="btn btn-outline-primary m-1" href="./search.html?search={{ popular['searchterm'] }}">
126126
{{ popular['searchterm'] }} <span class="badge bg-info">{{ popular['number'] }}x</span>
127127
</a>
128128
{% endfor %}

phpmyfaq/src/admin-api-routes.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
declare(strict_types=1);
1919

20+
use phpMyFAQ\Controller\Administration\Api\AdminLogController;
2021
use phpMyFAQ\Controller\Administration\Api\AttachmentController;
2122
use phpMyFAQ\Controller\Administration\Api\CategoryController;
2223
use phpMyFAQ\Controller\Administration\Api\CommentController;
@@ -522,6 +523,11 @@
522523
'methods' => 'POST',
523524
],
524525
// Statistics API
526+
'admin.api.statistics.adminlog.export' => [
527+
'path' => '/statistics/admin-log/export',
528+
'controller' => [AdminLogController::class, 'export'],
529+
'methods' => 'POST',
530+
],
525531
'admin.api.statistics.adminlog.delete' => [
526532
'path' => '/statistics/admin-log',
527533
'controller' => [StatisticsController::class, 'deleteAdminLog'],

phpmyfaq/src/phpMyFAQ/Controller/Administration/AdminLogController.php

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use phpMyFAQ\Enums\PermissionType;
2424
use phpMyFAQ\Filter;
2525
use phpMyFAQ\Pagination;
26+
use phpMyFAQ\Pagination\UrlConfig;
2627
use phpMyFAQ\Session\Token;
2728
use phpMyFAQ\Translation;
2829
use phpMyFAQ\Twig\Extensions\UserNameTwigExtension;
@@ -46,16 +47,14 @@ public function index(Request $request): Response
4647
$this->userHasPermission(PermissionType::STATISTICS_ADMINLOG);
4748

4849
$itemsPerPage = 15;
49-
$page = Filter::filterVar($request->attributes->get('page'), FILTER_VALIDATE_INT, 1);
50+
$page = Filter::filterVar($request->query->get('page'), FILTER_VALIDATE_INT, 1);
5051

51-
// Pagination options
52-
$options = [
53-
'baseUrl' => $request->getUri(),
54-
'total' => $this->adminLog->getNumberOfEntries(),
55-
'perPage' => $itemsPerPage,
56-
'pageParamName' => 'page',
57-
];
58-
$pagination = new Pagination($options);
52+
$pagination = new Pagination(
53+
baseUrl: $request->getUri(),
54+
total: $this->adminLog->getNumberOfEntries(),
55+
perPage: $itemsPerPage,
56+
urlConfig: new UrlConfig(pageParamName: 'page'),
57+
);
5958

6059
$loggingData = $this->adminLog->getAll();
6160

@@ -68,7 +67,9 @@ public function index(Request $request): Response
6867
...$this->getHeader($request),
6968
...$this->getFooter(),
7069
'headerAdminLog' => Translation::get(key: 'ad_menu_adminlog'),
70+
'buttonExportAdminLog' => Translation::get(key: 'msgAdminLogExportCsv'),
7171
'buttonDeleteAdminLog' => Translation::get(key: 'ad_adminlog_del_older_30d'),
72+
'csrfExportAdminLogToken' => Token::getInstance($this->session)->getTokenString('export-adminlog'),
7273
'csrfDeleteAdminLogToken' => Token::getInstance($this->session)->getTokenString('delete-adminlog'),
7374
'currentLocale' => $this->configuration->getLanguage()->getLanguage(),
7475
'pagination' => $pagination->render(),
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/**
4+
* The abstract Administration API controller
5+
*
6+
* This Source Code Form is subject to the terms of the Mozilla Public License,
7+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
8+
* obtain one at https://mozilla.org/MPL/2.0/.
9+
*
10+
* @package phpMyFAQ
11+
* @author Thorsten Rinne <[email protected]>
12+
* @copyright 2026 phpMyFAQ Team
13+
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14+
* @link https://www.phpmyfaq.de
15+
* @since 2026-01-06
16+
*/
17+
18+
namespace phpMyFAQ\Controller\Administration\Api;
19+
20+
use phpMyFAQ\Administration\AdminLog;
21+
use phpMyFAQ\Controller\AbstractController;
22+
23+
class AbstractAdministrationApiController extends AbstractController
24+
{
25+
protected ?AdminLog $adminLog = null;
26+
27+
public function __construct()
28+
{
29+
parent::__construct();
30+
31+
$this->adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log');
32+
}
33+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
/**
4+
* The Admin Log API Controller
5+
*
6+
* This Source Code Form is subject to the terms of the Mozilla Public License,
7+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
8+
* obtain one at https://mozilla.org/MPL/2.0/.
9+
*
10+
* @package phpMyFAQ
11+
* @author Thorsten Rinne <[email protected]>
12+
* @copyright 2026 phpMyFAQ Team
13+
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14+
* @link https://www.phpmyfaq.de
15+
* @since 2026-01-06
16+
*/
17+
18+
declare(strict_types=1);
19+
20+
namespace phpMyFAQ\Controller\Administration\Api;
21+
22+
use phpMyFAQ\Enums\AdminLogType;
23+
use phpMyFAQ\Enums\PermissionType;
24+
use phpMyFAQ\Session\Token;
25+
use phpMyFAQ\Translation;
26+
use phpMyFAQ\User;
27+
use Symfony\Component\HttpFoundation\JsonResponse;
28+
use Symfony\Component\HttpFoundation\Request;
29+
use Symfony\Component\HttpFoundation\Response;
30+
use Symfony\Component\Routing\Attribute\Route;
31+
32+
final class AdminLogController extends AbstractAdministrationApiController
33+
{
34+
/**
35+
* @throws \Exception
36+
*/
37+
#[Route(
38+
path: './admin/api/statistics/admin-log/export',
39+
name: 'admin.api.statistics.adminlog.export',
40+
methods: ['POST'],
41+
)]
42+
public function export(Request $request): Response|JsonResponse
43+
{
44+
$this->userHasPermission(PermissionType::STATISTICS_ADMINLOG);
45+
46+
$data = json_decode($request->getContent());
47+
48+
if (!Token::getInstance($this->session)->verifyToken('export-adminlog', $data->csrf)) {
49+
return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
50+
}
51+
52+
$loggingData = $this->adminLog->getAll();
53+
54+
$handle = fopen('php://temp', 'r+');
55+
fputcsv(
56+
$handle,
57+
['ID', 'Date/Time', 'User ID', 'Username', 'IP Address', 'Action'],
58+
separator: ',',
59+
enclosure: '"',
60+
eol: PHP_EOL,
61+
);
62+
63+
foreach ($loggingData as $log) {
64+
$user = new User($this->configuration);
65+
$user->getUserById($log->getUserId());
66+
$username = $user->getLogin();
67+
68+
fputcsv(
69+
$handle,
70+
[
71+
$log->getId(),
72+
date('Y-m-d H:i:s', $log->getTime()),
73+
$log->getUserId(),
74+
$username,
75+
$log->getIp(),
76+
$log->getText(),
77+
],
78+
separator: ',',
79+
enclosure: '"',
80+
eol: PHP_EOL,
81+
);
82+
}
83+
84+
rewind($handle);
85+
$content = stream_get_contents($handle);
86+
fclose($handle);
87+
88+
$this->adminLog->log($this->currentUser, AdminLogType::DATA_EXPORT_LOGS->value);
89+
90+
$response = new Response($content);
91+
$response->headers->set('Content-Type', 'text/csv');
92+
$response->headers->set(
93+
'Content-Disposition',
94+
'attachment; filename="admin-log-export-' . date('Y-m-d-His') . '.csv"',
95+
);
96+
97+
return $response;
98+
}
99+
}

0 commit comments

Comments
 (0)