- Handler funkce extrahovany z API souboru do api/admin/handlers/ - config.php rozdeleny na helpers.php (funkce) a constants.php (konstanty) - require_once odstranen z class souboru (AuditLog, JWTAuth, LeaveNotification) - vendor/autoload.php presunuto do config.php bootstrap - totp-handlers.php: pridany use deklarace pro TwoFactorAuth - phpstan.neon: bootstrapFiles, scanDirectories, dynamicConstantNames - Opraveny chybejici routing bloky v totp.php a session.php Vysledek: phpcs 0 errors 0 warnings, PHPStan 0 errors, ESLint 0 errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
358 lines
9.3 KiB
PHP
358 lines
9.3 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Sdilene helper funkce pro API
|
|
*
|
|
* Definuje pomocne funkce pouzivane v celé API.
|
|
* Tento soubor NEDELA zadne side effects - jen definuje funkce.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
function loadEnv(string $path): bool
|
|
{
|
|
if (!file_exists($path)) {
|
|
return false;
|
|
}
|
|
|
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
foreach ($lines as $line) {
|
|
if (strpos(trim($line), '#') === 0) {
|
|
continue;
|
|
}
|
|
|
|
$parts = explode('=', $line, 2);
|
|
if (count($parts) !== 2) {
|
|
continue;
|
|
}
|
|
|
|
$name = trim($parts[0]);
|
|
$value = trim($parts[1]);
|
|
|
|
if (
|
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
|
) {
|
|
$value = substr($value, 1, -1);
|
|
}
|
|
|
|
$_ENV[$name] = $value;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function env(string $key, mixed $default = null): mixed
|
|
{
|
|
return $_ENV[$key] ?? $default;
|
|
}
|
|
|
|
/**
|
|
* Get PDO database connection (singleton pattern)
|
|
*
|
|
* @return PDO Database connection instance
|
|
* @throws PDOException If connection fails
|
|
*/
|
|
function db(): PDO
|
|
{
|
|
static $pdo = null;
|
|
|
|
if ($pdo === null) {
|
|
$dsn = sprintf(
|
|
'mysql:host=%s;dbname=%s;charset=%s',
|
|
DB_HOST,
|
|
DB_NAME,
|
|
DB_CHARSET
|
|
);
|
|
|
|
$options = [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . DB_CHARSET,
|
|
];
|
|
|
|
try {
|
|
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
|
|
} catch (PDOException $e) {
|
|
if (DEBUG_MODE) {
|
|
throw $e;
|
|
}
|
|
error_log('Database connection failed: ' . $e->getMessage());
|
|
throw new PDOException('Database connection failed');
|
|
}
|
|
}
|
|
|
|
return $pdo;
|
|
}
|
|
|
|
/**
|
|
* Set CORS headers for API responses
|
|
*/
|
|
function setCorsHeaders(): void
|
|
{
|
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
|
|
if (in_array($origin, CORS_ALLOWED_ORIGINS)) {
|
|
header("Access-Control-Allow-Origin: $origin");
|
|
header('Access-Control-Allow-Credentials: true');
|
|
} elseif (DEBUG_MODE && str_starts_with($origin, 'http://127.0.0.1:')) {
|
|
header("Access-Control-Allow-Origin: $origin");
|
|
header('Access-Control-Allow-Credentials: true');
|
|
}
|
|
// Neznamy origin = zadny CORS header
|
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
|
header('Access-Control-Max-Age: 86400');
|
|
|
|
// Handle preflight requests
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(200);
|
|
exit();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send JSON response and exit
|
|
*
|
|
* @param mixed $data Data to send
|
|
* @param int $statusCode HTTP status code
|
|
*/
|
|
function jsonResponse($data, int $statusCode = 200): void
|
|
{
|
|
http_response_code($statusCode);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Send error response
|
|
*
|
|
* @param string $message Error message
|
|
* @param int $statusCode HTTP status code
|
|
*/
|
|
function errorResponse(string $message, int $statusCode = 400): void
|
|
{
|
|
jsonResponse(['success' => false, 'error' => $message], $statusCode);
|
|
}
|
|
|
|
/**
|
|
* Send success response
|
|
*
|
|
* @param mixed $data Data to include
|
|
* @param string $message Optional message
|
|
*/
|
|
function successResponse($data = null, string $message = ''): void
|
|
{
|
|
$response = ['success' => true];
|
|
if ($message) {
|
|
$response['message'] = $message;
|
|
}
|
|
if ($data !== null) {
|
|
$response['data'] = $data;
|
|
}
|
|
jsonResponse($response);
|
|
}
|
|
|
|
/**
|
|
* Get JSON request body
|
|
*
|
|
* @return array<string, mixed> Decoded JSON data
|
|
*/
|
|
function getJsonInput(): array
|
|
{
|
|
$input = file_get_contents('php://input');
|
|
$data = json_decode($input, true);
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
/**
|
|
* Sanitize string input
|
|
*
|
|
* @param string $input Input string
|
|
* @return string Sanitized string
|
|
*/
|
|
function sanitize(string $input): string
|
|
{
|
|
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
/**
|
|
* Validate email format
|
|
*
|
|
* @param string $email Email to validate
|
|
* @return bool True if valid
|
|
*/
|
|
function isValidEmail(string $email): bool
|
|
{
|
|
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
|
}
|
|
|
|
/**
|
|
* Validate and sanitize month parameter (YYYY-MM format)
|
|
*/
|
|
function validateMonth(string $param = 'month'): string
|
|
{
|
|
$month = $_GET[$param] ?? date('Y-m');
|
|
if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
|
|
$month = date('Y-m');
|
|
}
|
|
return $month;
|
|
}
|
|
|
|
/**
|
|
* Get client IP address
|
|
*
|
|
* Uses only REMOTE_ADDR which cannot be spoofed (TCP connection IP).
|
|
* If you add a reverse proxy (Cloudflare, Nginx, etc.) in the future,
|
|
* update this function to trust specific proxy headers only from known proxy IPs.
|
|
*
|
|
* @return string IP address
|
|
*/
|
|
function getClientIp(): string
|
|
{
|
|
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
|
}
|
|
|
|
/**
|
|
* Set security headers for API responses
|
|
*
|
|
* Sets standard security headers to protect against common web vulnerabilities:
|
|
* - X-Content-Type-Options: Prevents MIME type sniffing
|
|
* - X-Frame-Options: Prevents clickjacking attacks
|
|
* - X-XSS-Protection: Enables browser XSS filter
|
|
* - Referrer-Policy: Controls referrer information sent with requests
|
|
*
|
|
* Note: Content-Security-Policy is not set here as it may interfere with the React frontend
|
|
*/
|
|
function setSecurityHeaders(): void
|
|
{
|
|
header('X-Content-Type-Options: nosniff');
|
|
header('X-Frame-Options: DENY');
|
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
|
if (!DEBUG_MODE && isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
|
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set no-cache headers
|
|
*
|
|
* Prevents browser caching for sensitive endpoints
|
|
*/
|
|
function setNoCacheHeaders(): void
|
|
{
|
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
|
header('Cache-Control: post-check=0, pre-check=0', false);
|
|
header('Pragma: no-cache');
|
|
}
|
|
|
|
/**
|
|
* Sdilene generovani cisel pro objednavky a projekty (spolecny ciselny prostor)
|
|
*/
|
|
function generateSharedNumber(PDO $pdo): string
|
|
{
|
|
$yy = date('y');
|
|
|
|
$settings = $pdo->query('SELECT order_type_code FROM company_settings LIMIT 1')->fetch();
|
|
$typeCode = ($settings && !empty($settings['order_type_code'])) ? $settings['order_type_code'] : '71';
|
|
|
|
$prefix = $yy . $typeCode;
|
|
$prefixLen = strlen($prefix);
|
|
$likePattern = $prefix . '%';
|
|
|
|
$stmt = $pdo->prepare('
|
|
SELECT COALESCE(MAX(seq), 0) FROM (
|
|
SELECT CAST(SUBSTRING(order_number, ? + 1) AS UNSIGNED) AS seq
|
|
FROM orders WHERE order_number LIKE ?
|
|
UNION ALL
|
|
SELECT CAST(SUBSTRING(project_number, ? + 1) AS UNSIGNED) AS seq
|
|
FROM projects WHERE project_number LIKE ?
|
|
) combined
|
|
');
|
|
$stmt->execute([$prefixLen, $likePattern, $prefixLen, $likePattern]);
|
|
$max = (int) $stmt->fetchColumn();
|
|
|
|
return sprintf('%s%s%04d', $yy, $typeCode, $max + 1);
|
|
}
|
|
|
|
/**
|
|
* Get permissions for a user by their ID
|
|
* Cached per-request via static variable
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
function getUserPermissions(int $userId): array
|
|
{
|
|
static $cache = [];
|
|
|
|
if (isset($cache[$userId])) {
|
|
return $cache[$userId];
|
|
}
|
|
|
|
try {
|
|
$pdo = db();
|
|
|
|
$stmt = $pdo->prepare('
|
|
SELECT r.name FROM users u
|
|
JOIN roles r ON u.role_id = r.id
|
|
WHERE u.id = ?
|
|
');
|
|
$stmt->execute([$userId]);
|
|
$role = $stmt->fetch();
|
|
|
|
if ($role && $role['name'] === 'admin') {
|
|
$stmt = $pdo->query('SELECT name FROM permissions');
|
|
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
|
return $cache[$userId];
|
|
}
|
|
|
|
$stmt = $pdo->prepare('
|
|
SELECT p.name
|
|
FROM permissions p
|
|
JOIN role_permissions rp ON p.id = rp.permission_id
|
|
JOIN users u ON u.role_id = rp.role_id
|
|
WHERE u.id = ?
|
|
');
|
|
$stmt->execute([$userId]);
|
|
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
|
return $cache[$userId];
|
|
} catch (PDOException $e) {
|
|
error_log('getUserPermissions error: ' . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require a specific permission, return 403 if denied
|
|
*
|
|
* @param array<string, mixed> $authData
|
|
*/
|
|
function requirePermission(array $authData, string $permission): void
|
|
{
|
|
if ($authData['user']['is_admin'] ?? false) {
|
|
return;
|
|
}
|
|
|
|
$permissions = getUserPermissions($authData['user_id']);
|
|
if (!in_array($permission, $permissions)) {
|
|
errorResponse('Přístup odepřen. Nemáte potřebná oprávnění.', 403);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user has a specific permission (returns bool)
|
|
*
|
|
* @param array<string, mixed> $authData
|
|
*/
|
|
function hasPermission(array $authData, string $permission): bool
|
|
{
|
|
if ($authData['user']['is_admin'] ?? false) {
|
|
return true;
|
|
}
|
|
|
|
$permissions = getUserPermissions($authData['user_id']);
|
|
return in_array($permission, $permissions);
|
|
}
|