ver. 0.308: kolory statusow zamowien + poprawki bezpieczenstwa

- Kolorowe badge statusow na liscie zamowien (pp_shop_statuses.color)
- Walidacja hex koloru z DB (regex), sanityzacja HTML transport
- Polaczenie 2 zapytan SQL w jedno orderStatusData()
- Path-based form submit w table-list.php (admin URL routing)
- 11 nowych testow (750 total, 2114 assertions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 20:57:56 +01:00
parent 56c931f7da
commit efcf06969c
10 changed files with 236 additions and 18 deletions

View File

@@ -36,7 +36,7 @@ composer test
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
Current suite: **739 tests, 2089 assertions**.
Current suite: **750 tests, 2114 assertions**.
### Creating Updates
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs.

View File

@@ -9,7 +9,7 @@ $buildUrl = function(array $params = []) use ($list): string {
}
}
$qs = http_build_query($query);
return $list->basePath . ($qs ? ('?' . $qs) : '');
return $list->basePath . $qs;
};
$currentSort = $list->sort['column'] ?? '';
@@ -92,7 +92,7 @@ $isCompactColumn = function(array $column): bool {
<div class="panel-body">
<div class="js-table-filters-wrapper table-filters-wrapper<?= $hasActiveFilters ? ' open' : ''; ?>">
<form method="get" action="<?= htmlspecialchars($list->basePath, ENT_QUOTES, 'UTF-8'); ?>" class="row mb15 js-table-filters-form">
<form method="get" action="<?= htmlspecialchars($list->basePath, ENT_QUOTES, 'UTF-8'); ?>" data-path-submit="<?= htmlspecialchars($list->basePath, ENT_QUOTES, 'UTF-8'); ?>" class="row mb15 js-table-filters-form">
<?php foreach ($list->filters as $filter): ?>
<?php
$filterKey = (string)($filter['key'] ?? '');
@@ -292,7 +292,7 @@ $isCompactColumn = function(array $column): bool {
</ul>
</div>
<div class="col-sm-6 text-right">
<form method="get" action="<?= htmlspecialchars($list->basePath, ENT_QUOTES, 'UTF-8'); ?>" class="form-inline table-list-per-page-form">
<form method="get" action="<?= htmlspecialchars($list->basePath, ENT_QUOTES, 'UTF-8'); ?>" data-path-submit="<?= htmlspecialchars($list->basePath, ENT_QUOTES, 'UTF-8'); ?>" class="form-inline table-list-per-page-form">
<?php foreach ($list->query as $key => $value): ?>
<?php if ($key !== 'per_page' && $key !== 'page'): ?>
<input type="hidden" name="<?= htmlspecialchars((string)$key, ENT_QUOTES, 'UTF-8'); ?>" value="<?= htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); ?>" />
@@ -300,7 +300,7 @@ $isCompactColumn = function(array $column): bool {
<?php endforeach; ?>
<input type="hidden" name="page" value="1" />
Wyświetlaj
<select name="per_page" class="form-control input-sm" onchange="this.form.submit()">
<select name="per_page" class="form-control input-sm js-per-page-select">
<?php foreach ($list->perPageOptions as $opt): ?>
<option value="<?= (int)$opt; ?>"<?= ((int)$opt === $perPage) ? ' selected="selected"' : ''; ?>><?= (int)$opt; ?></option>
<?php endforeach; ?>
@@ -529,5 +529,26 @@ $isCompactColumn = function(array $column): bool {
saveFilterState(true);
}
});
// --- Path-based form submission (admin URL routing) ---
$(document).off('submit.tablePathSubmit', 'form[data-path-submit]');
$(document).on('submit.tablePathSubmit', 'form[data-path-submit]', function(e) {
e.preventDefault();
var basePath = $(this).attr('data-path-submit');
var data = $(this).serializeArray();
var parts = [];
for (var i = 0; i < data.length; i++) {
if (String(data[i].value) !== '') {
parts.push(encodeURIComponent(data[i].name) + '=' + encodeURIComponent(data[i].value));
}
}
window.location.href = basePath + (parts.length ? parts.join('&') : '');
});
// Per-page select auto-submit
$(document).off('change.tablePerPage', '.js-per-page-select');
$(document).on('change.tablePerPage', '.js-per-page-select', function() {
$(this).closest('form').trigger('submit');
});
})(window.jQuery);
</script>

View File

@@ -30,6 +30,14 @@ class OrderAdminService
return $this->orders->orderStatuses();
}
/**
* @return array{names: array<int, string>, colors: array<int, string>}
*/
public function statusData(): array
{
return $this->orders->orderStatusData();
}
/**
* @return array{items: array<int, array<string, mixed>>, total: int}
*/

View File

@@ -245,25 +245,43 @@ class OrderRepository
public function orderStatuses(): array
{
$rows = $this->db->select('pp_shop_statuses', ['id', 'status'], [
$data = $this->orderStatusData();
return $data['names'];
}
/**
* Zwraca nazwy i kolory statusów w jednym zapytaniu.
*
* @return array{names: array<int, string>, colors: array<int, string>}
*/
public function orderStatusData(): array
{
$rows = $this->db->select('pp_shop_statuses', ['id', 'status', 'color'], [
'ORDER' => ['o' => 'ASC'],
]);
$names = [];
$colors = [];
if (!is_array($rows)) {
return [];
return ['names' => $names, 'colors' => $colors];
}
$result = [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id < 0) {
continue;
}
$result[$id] = (string)($row['status'] ?? '');
$names[$id] = (string)($row['status'] ?? '');
$color = trim((string)($row['color'] ?? ''));
if ($color !== '' && preg_match('/^#[0-9a-fA-F]{3,6}$/', $color)) {
$colors[$id] = $color;
}
}
return $result;
return ['names' => $names, 'colors' => $colors];
}
public function nextOrderId(int $orderId): ?int

View File

@@ -69,7 +69,9 @@ class ShopOrderController
$listRequest['perPage']
);
$statusesMap = $this->service->statuses();
$statusData = $this->service->statusData();
$statusesMap = $statusData['names'];
$statusColorsMap = $statusData['colors'];
$rows = [];
$lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1;
@@ -77,7 +79,15 @@ class ShopOrderController
$orderId = (int)($item['id'] ?? 0);
$orderNumber = (string)($item['number'] ?? '');
$statusId = (int)($item['status'] ?? 0);
$statusLabel = (string)($statusesMap[$statusId] ?? ('Status #' . $statusId));
$statusLabel = htmlspecialchars((string)($statusesMap[$statusId] ?? ('Status #' . $statusId)), ENT_QUOTES, 'UTF-8');
$statusColor = isset($statusColorsMap[$statusId]) ? $statusColorsMap[$statusId] : '';
if ($statusColor !== '') {
$textColor = $this->contrastTextColor($statusColor);
$statusHtml = '<span class="label" style="background-color:' . htmlspecialchars($statusColor, ENT_QUOTES, 'UTF-8') . ';color:' . $textColor . '">' . $statusLabel . '</span>';
} else {
$statusHtml = $statusLabel;
}
$rows[] = [
'lp' => $lp++ . '.',
@@ -86,13 +96,13 @@ class ShopOrderController
'paid' => ((int)($item['paid'] ?? 0) === 1)
? '<i class="fa fa-check text-success"></i>'
: '<i class="fa fa-times text-dark"></i>',
'status' => htmlspecialchars($statusLabel, ENT_QUOTES, 'UTF-8'),
'status' => $statusHtml,
'summary' => number_format((float)($item['summary'] ?? 0), 2, '.', ' ') . ' zł',
'client' => htmlspecialchars((string)($item['client'] ?? ''), ENT_QUOTES, 'UTF-8') . ' | zamówienia: <strong>' . (int)($item['total_orders'] ?? 0) . '</strong>',
'address' => (string)($item['address'] ?? ''),
'order_email' => (string)($item['order_email'] ?? ''),
'client_phone' => (string)($item['client_phone'] ?? ''),
'transport' => (string)($item['transport'] ?? ''),
'transport' => $this->sanitizeInlineHtml((string)($item['transport'] ?? '')),
'payment_method' => (string)($item['payment_method'] ?? ''),
'_actions' => [
[
@@ -127,7 +137,7 @@ class ShopOrderController
['key' => 'address', 'label' => 'Adres', 'sortable' => false],
['key' => 'order_email', 'sort_key' => 'order_email', 'label' => 'Email', 'sortable' => true],
['key' => 'client_phone', 'sort_key' => 'client_phone', 'label' => 'Telefon', 'sortable' => true],
['key' => 'transport', 'sort_key' => 'transport', 'label' => 'Dostawa', 'sortable' => true],
['key' => 'transport', 'sort_key' => 'transport', 'label' => 'Dostawa', 'sortable' => true, 'raw' => true],
['key' => 'payment_method', 'sort_key' => 'payment_method', 'label' => 'Płatność', 'sortable' => true],
],
$rows,
@@ -361,4 +371,26 @@ class ShopOrderController
return date('Y-m-d H:i', $ts);
}
}
private function contrastTextColor(string $hex): string
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6) {
return '#fff';
}
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
$luminance = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255;
return $luminance > 0.5 ? '#000' : '#fff';
}
private function sanitizeInlineHtml(string $html): string
{
$html = strip_tags($html, '<b><strong><i><em>');
return preg_replace('/<(b|strong|i|em)\s[^>]*>/i', '<$1>', $html);
}
}

View File

@@ -4,6 +4,17 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
---
## ver. 0.308 (2026-02-22) - Kolory statusow zamowien + poprawki bezpieczenstwa
- **NEW**: Kolorowe badge statusow na liscie zamowien w admin panelu — kolory pobierane z `pp_shop_statuses.color`, kontrast tekstu obliczany automatycznie
- **FIX**: Walidacja formatu hex koloru z bazy (`/^#[0-9a-fA-F]{3,6}$/`) — odrzucanie nieprawidlowych wartosci
- **FIX**: Sanityzacja HTML w kolumnie "Dostawa" — `strip_tags()` + regex usuwajacy atrybuty z dozwolonych tagow (zapobieganie XSS via `onclick` itp.)
- **OPTYMALIZACJA**: Polaczenie dwoch zapytan SQL do `pp_shop_statuses` (nazwy + kolory) w jedno `orderStatusData()`
- **ZMIANA**: Path-based form submit w `table-list.php` — formularze filtrow i per-page uzywaja JS interceptora z `data-path-submit` zamiast natywnego GET, kompatybilne z admin URL routing
- **NEW**: 11 nowych testow jednostkowych (750 total, 2114 assertions)
---
## ver. 0.307 (2026-02-22) - Przycisk sprawdzania aktualizacji + auto-changelog
- **NEW**: Przycisk "Sprawdz aktualizacje" w panelu admina — ikona odswiezenia obok numeru wersji, klik odpytuje serwer AJAX-em i pokazuje/ukrywa badge "aktualizacja" bez przeladowania strony

View File

@@ -23,7 +23,7 @@ composer test # standard
## Aktualny stan
```text
OK (739 tests, 2089 assertions)
OK (750 tests, 2114 assertions)
```
Zweryfikowano: 2026-02-22 (ver. 0.304)

View File

@@ -29,6 +29,66 @@ class OrderRepositoryTest extends TestCase
$this->assertSame('W realizacji', $statuses[4]);
}
public function testOrderStatusDataReturnsBothNamesAndColors(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('select')
->willReturnCallback(function ($table, $columns, $where) {
if ($table === 'pp_shop_statuses') {
return [
['id' => 0, 'status' => 'Nowe', 'color' => '#ff0000'],
['id' => 4, 'status' => 'W realizacji', 'color' => '#00ff00'],
['id' => 5, 'status' => 'Wysłane', 'color' => ''],
];
}
return [];
});
$repository = new OrderRepository($mockDb);
$data = $repository->orderStatusData();
$this->assertArrayHasKey('names', $data);
$this->assertArrayHasKey('colors', $data);
$this->assertSame('Nowe', $data['names'][0]);
$this->assertSame('W realizacji', $data['names'][4]);
$this->assertSame('Wysłane', $data['names'][5]);
$this->assertSame('#ff0000', $data['colors'][0]);
$this->assertSame('#00ff00', $data['colors'][4]);
$this->assertArrayNotHasKey(5, $data['colors']);
}
public function testOrderStatusDataFiltersInvalidHexColors(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('select')
->willReturn([
['id' => 1, 'status' => 'OK', 'color' => '#abc'],
['id' => 2, 'status' => 'Bad', 'color' => 'red'],
['id' => 3, 'status' => 'XSS', 'color' => '#000" onclick="alert(1)'],
['id' => 4, 'status' => 'Valid', 'color' => '#AABBCC'],
]);
$repository = new OrderRepository($mockDb);
$data = $repository->orderStatusData();
$this->assertSame('#abc', $data['colors'][1]);
$this->assertArrayNotHasKey(2, $data['colors']);
$this->assertArrayNotHasKey(3, $data['colors']);
$this->assertSame('#AABBCC', $data['colors'][4]);
}
public function testOrderStatusDataReturnsEmptyOnDbFailure(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('select')->willReturn(false);
$repository = new OrderRepository($mockDb);
$data = $repository->orderStatusData();
$this->assertSame([], $data['names']);
$this->assertSame([], $data['colors']);
}
public function testNextAndPrevOrderIdReturnNullForInvalidInput(): void
{
$mockDb = $this->createMock(\medoo::class);

View File

@@ -85,4 +85,72 @@ class ShopOrderControllerTest extends TestCase
$this->assertEquals('Domain\\Product\\ProductRepository', $params[1]->getType()->getName());
$this->assertTrue($params[1]->isOptional());
}
// --- contrastTextColor tests (via reflection) ---
public function testContrastTextColorReturnsBlackForLightColor(): void
{
$result = $this->invokePrivate('contrastTextColor', ['#ffffff']);
$this->assertSame('#000', $result);
}
public function testContrastTextColorReturnsWhiteForDarkColor(): void
{
$result = $this->invokePrivate('contrastTextColor', ['#000000']);
$this->assertSame('#fff', $result);
}
public function testContrastTextColorHandlesShortHex(): void
{
$result = $this->invokePrivate('contrastTextColor', ['#fff']);
$this->assertSame('#000', $result);
$result = $this->invokePrivate('contrastTextColor', ['#000']);
$this->assertSame('#fff', $result);
}
public function testContrastTextColorDefaultsToWhiteForInvalidHex(): void
{
$result = $this->invokePrivate('contrastTextColor', ['invalid']);
$this->assertSame('#fff', $result);
$result = $this->invokePrivate('contrastTextColor', ['#zz']);
$this->assertSame('#fff', $result);
}
// --- sanitizeInlineHtml tests (via reflection) ---
public function testSanitizeInlineHtmlStripsDisallowedTags(): void
{
$result = $this->invokePrivate('sanitizeInlineHtml', ['<b>Bold</b> <script>alert(1)</script> <em>Italic</em>']);
$this->assertSame('<b>Bold</b> alert(1) <em>Italic</em>', $result);
}
public function testSanitizeInlineHtmlStripsAttributesFromAllowedTags(): void
{
$result = $this->invokePrivate('sanitizeInlineHtml', ['<b onclick="alert(1)">Bold</b>']);
$this->assertSame('<b>Bold</b>', $result);
$result = $this->invokePrivate('sanitizeInlineHtml', ['<strong style="color:red" class="x">text</strong>']);
$this->assertSame('<strong>text</strong>', $result);
}
public function testSanitizeInlineHtmlPreservesCleanTags(): void
{
$result = $this->invokePrivate('sanitizeInlineHtml', ['<b>Bold</b> <i>Italic</i> <strong>Strong</strong> <em>Em</em>']);
$this->assertSame('<b>Bold</b> <i>Italic</i> <strong>Strong</strong> <em>Em</em>', $result);
}
public function testSanitizeInlineHtmlHandlesPlainText(): void
{
$result = $this->invokePrivate('sanitizeInlineHtml', ['Kurier DPD']);
$this->assertSame('Kurier DPD', $result);
}
private function invokePrivate(string $method, array $args)
{
$reflection = new \ReflectionMethod($this->controller, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($this->controller, $args);
}
}

View File

@@ -1,5 +1,5 @@
<?
$current_ver = 307;
$current_ver = 308;
for ($i = 1; $i <= $current_ver; $i++)
{