fix: oprava kritických bezpečnostních chyb a bugů z code review

- SEC-1: nahrazen exec('fsutil') za PHP-native is_link()+realpath() v NasFileManager - eliminace command injection
- SEC-2: přidáno ověření aktuálního hesla při změně hesla (profile.php + DashProfile.jsx)
- BUG-1: attendance punch obalen do transakce s SELECT FOR UPDATE - prevence race condition při dvojkliku
- BUG-2: eliminován N+1 SQL dotaz pro VAT v invoice listu - výpočet přesunut do subquery
- BUG-5/6: delete a update attendance záznamů obaleny do transakcí - prevence nekonzistentního stavu
- BUG-7: opravena duplikace nabídky - přidáno chybějící pole unit v offer items

ESLint: 0 errors | PHPCS: 0 errors | Build: OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 13:46:20 +01:00
parent 913344b8c4
commit 5550358b15
45 changed files with 373 additions and 301 deletions

View File

@@ -261,106 +261,122 @@ function handlePunch(PDO $pdo, int $userId): void
$lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null;
$lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null;
$accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null;
$address = !empty($input['address']) ? $input['address'] : null;
$address = !empty($input['address']) ? mb_substr($input['address'], 0, 500) : null;
$stmt = $pdo->prepare("
SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, created_at
FROM attendance
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY created_at DESC LIMIT 1
");
$stmt->execute([$userId]);
$ongoingShift = $stmt->fetch();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, created_at
FROM attendance
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY created_at DESC LIMIT 1
FOR UPDATE
");
$stmt->execute([$userId]);
$ongoingShift = $stmt->fetch();
if ($action === 'arrival' && !$ongoingShift) {
$now = roundUpTo15Minutes($rawNow);
$stmt = $pdo->prepare('
INSERT INTO attendance
(user_id, shift_date, arrival_time, arrival_lat, arrival_lng, arrival_accuracy, arrival_address)
VALUES (?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([$userId, $today, $now, $lat, $lng, $accuracy, $address]);
if ($action === 'arrival' && !$ongoingShift) {
$now = roundUpTo15Minutes($rawNow);
$stmt = $pdo->prepare('
INSERT INTO attendance
(user_id, shift_date, arrival_time, arrival_lat, arrival_lng, arrival_accuracy, arrival_address)
VALUES (?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([$userId, $today, $now, $lat, $lng, $accuracy, $address]);
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
'arrival_time' => $now,
'location' => $address,
], 'Příchod zaznamenán');
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
'arrival_time' => $now,
'location' => $address,
], 'Příchod zaznamenán');
successResponse(null, 'Příchod zaznamenán');
} elseif ($ongoingShift) {
switch ($action) {
case 'break_start':
if ($ongoingShift['arrival_time'] && !$ongoingShift['break_start']) {
$breakStart = roundToNearest10Minutes($rawNow);
$breakEnd = date('Y-m-d H:i:s', strtotime($breakStart) + (30 * 60));
$pdo->commit();
successResponse(null, 'Příchod zaznamenán');
} elseif ($ongoingShift) {
switch ($action) {
case 'break_start':
if ($ongoingShift['arrival_time'] && !$ongoingShift['break_start']) {
$breakStart = roundToNearest10Minutes($rawNow);
$breakEnd = date('Y-m-d H:i:s', strtotime($breakStart) + (30 * 60));
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
successResponse(null, 'Pauza zaznamenána');
} else {
errorResponse('Nelze zadat pauzu');
}
break;
case 'departure':
if ($ongoingShift['arrival_time'] && !$ongoingShift['departure_time']) {
$now = roundDownTo15Minutes($rawNow);
// Auto-add break if shift is longer than 6h and no break
if (!$ongoingShift['break_start'] && !$ongoingShift['break_end']) {
$arrivalTime = strtotime($ongoingShift['arrival_time']);
$departureTime = strtotime($now);
$hoursWorked = ($departureTime - $arrivalTime) / 3600;
if ($hoursWorked > 12) {
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60)));
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60)));
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
} elseif ($hoursWorked > 6) {
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60)));
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60)));
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
}
$pdo->commit();
successResponse(null, 'Pauza zaznamenána');
} else {
$pdo->rollBack();
errorResponse('Nelze zadat pauzu');
}
break;
$stmt = $pdo->prepare('
UPDATE attendance
SET departure_time = ?, departure_lat = ?, departure_lng = ?,
departure_accuracy = ?, departure_address = ?
WHERE id = ?
');
$stmt->execute([$now, $lat, $lng, $accuracy, $address, $ongoingShift['id']]);
case 'departure':
if ($ongoingShift['arrival_time'] && !$ongoingShift['departure_time']) {
$now = roundDownTo15Minutes($rawNow);
// Close any open project log
$stmt = $pdo->prepare('
UPDATE attendance_project_logs SET ended_at = ? WHERE attendance_id = ? AND ended_at IS NULL
');
$stmt->execute([$now, $ongoingShift['id']]);
// Auto-add break if shift is longer than 6h and no break
if (!$ongoingShift['break_start'] && !$ongoingShift['break_end']) {
$arrivalTime = strtotime($ongoingShift['arrival_time']);
$departureTime = strtotime($now);
$hoursWorked = ($departureTime - $arrivalTime) / 3600;
AuditLog::logUpdate('attendance', $ongoingShift['id'], [], [
'departure_time' => $now,
'location' => $address,
], 'Odchod zaznamenán');
if ($hoursWorked > 12) {
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60)));
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60)));
successResponse(null, 'Odchod zaznamenán');
} else {
errorResponse('Nelze zadat odchod');
}
break;
$sql = 'UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?';
$stmt = $pdo->prepare($sql);
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
} elseif ($hoursWorked > 6) {
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60)));
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60)));
default:
errorResponse('Neplatná akce');
$sql = 'UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?';
$stmt = $pdo->prepare($sql);
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
}
}
$stmt = $pdo->prepare('
UPDATE attendance
SET departure_time = ?, departure_lat = ?, departure_lng = ?,
departure_accuracy = ?, departure_address = ?
WHERE id = ?
');
$stmt->execute([$now, $lat, $lng, $accuracy, $address, $ongoingShift['id']]);
// Close any open project log
$stmt = $pdo->prepare('
UPDATE attendance_project_logs SET ended_at = ? WHERE attendance_id = ? AND ended_at IS NULL
');
$stmt->execute([$now, $ongoingShift['id']]);
AuditLog::logUpdate('attendance', $ongoingShift['id'], [], [
'departure_time' => $now,
'location' => $address,
], 'Odchod zaznamenán');
$pdo->commit();
successResponse(null, 'Odchod zaznamenán');
} else {
$pdo->rollBack();
errorResponse('Nelze zadat odchod');
}
break;
default:
$pdo->rollBack();
errorResponse('Neplatná akce');
}
} else {
$pdo->rollBack();
errorResponse('Neplatná akce - nemáte aktivní směnu');
}
} else {
errorResponse('Neplatná akce - nemáte aktivní směnu');
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
}

View File

@@ -213,6 +213,8 @@ function handleGetList(PDO $pdo): void
c.name as customer_name,
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0)
FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal,
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price * ii.vat_rate / 100), 0)
FROM invoice_items ii WHERE ii.invoice_id = i.id) as vat_amount,
o.order_number
{$from} {$where}
ORDER BY {$p['sort']} {$p['order']}",
@@ -222,20 +224,11 @@ function handleGetList(PDO $pdo): void
$invoices = $result['items'];
// Dopocitat celkovou castku s DPH
foreach ($invoices as &$inv) {
$subtotal = (float) $inv['subtotal'];
if ($inv['apply_vat']) {
$vatStmt = $pdo->prepare('
SELECT COALESCE(SUM(quantity * unit_price * vat_rate / 100), 0)
FROM invoice_items WHERE invoice_id = ?
');
$vatStmt->execute([$inv['id']]);
$vatAmount = (float) $vatStmt->fetchColumn();
$inv['total'] = $subtotal + $vatAmount;
} else {
$inv['total'] = $subtotal;
}
$inv['total'] = $inv['apply_vat']
? $subtotal + (float) $inv['vat_amount']
: $subtotal;
}
unset($inv);

View File

@@ -428,6 +428,7 @@ function handleDuplicate(PDO $pdo, int $sourceId): void
'description' => $item['description'],
'item_description' => $item['item_description'],
'quantity' => $item['quantity'],
'unit' => $item['unit'] ?? '',
'unit_price' => $item['unit_price'],
'is_included_in_total' => $item['is_included_in_total'],
'position' => $item['position'],

View File

@@ -34,10 +34,10 @@ try {
$pdo = db();
$userId = $authData['user_id'];
// Get existing user
// Get existing user (vcetne password_hash pro overeni aktualniho hesla)
$stmt = $pdo->prepare('
SELECT id, username, email, first_name, last_name, role_id, is_active,
last_login, created_at
last_login, created_at, password_hash
FROM users WHERE id = ?
');
$stmt->execute([$userId]);
@@ -75,6 +75,14 @@ try {
// Update user
if (!empty($input['password'])) {
// Overeni aktualniho hesla
if (empty($input['current_password'])) {
errorResponse('Pro změnu hesla je nutné zadat aktuální heslo');
}
if (!password_verify($input['current_password'], $existingUser['password_hash'])) {
errorResponse('Aktuální heslo není správné');
}
// Validate password length
if (strlen($input['password']) < 8) {
errorResponse('Heslo musí mít alespoň 8 znaků');