Initial commit

This commit is contained in:
2026-03-12 12:43:56 +01:00
commit f733dee856
137 changed files with 51192 additions and 0 deletions

405
api/config.php Normal file
View File

@@ -0,0 +1,405 @@
<?php
/**
* BOHA Automation - API Configuration
*
* Database and application configuration
*/
declare(strict_types=1);
// Load .env file
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]);
// Remove quotes if present
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;
}
// Load .env from api directory
loadEnv(__DIR__ . '/.env');
// Helper function to get env value with default
function env(string $key, mixed $default = null): mixed
{
return $_ENV[$key] ?? $default;
}
// Environment
define('APP_ENV', env('APP_ENV', 'production'));
define('DEBUG_MODE', APP_ENV === 'local');
if (DEBUG_MODE) {
error_reporting(E_ALL);
ini_set('display_errors', 1);
} else {
error_reporting(0);
ini_set('display_errors', 0);
}
// Database configuration
define('DB_HOST', env('DB_HOST', 'localhost'));
define('DB_NAME', env('DB_NAME', ''));
define('DB_USER', env('DB_USER', ''));
define('DB_PASS', env('DB_PASS', ''));
define('DB_CHARSET', 'utf8mb4');
// Security configuration
define('MAX_LOGIN_ATTEMPTS', 5);
define('LOCKOUT_MINUTES', 15);
define('BCRYPT_COST', 12);
// CORS - aktualizuj po nasazeni na subdomenu
define('CORS_ALLOWED_ORIGINS', [
'http://www.boha-automation.cz',
'https://www.boha-automation.cz',
]);
// Paths
define('API_ROOT', __DIR__);
define('INCLUDES_PATH', API_ROOT . '/includes');
// Rate limiting
define('RATE_LIMIT_STORAGE_PATH', __DIR__ . '/rate_limits');
/**
* 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();
// Check if user has admin role (superuser bypass)
$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') {
// Admin gets all permissions
$stmt = $pdo->query('SELECT name FROM permissions');
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
return $cache[$userId];
}
// Regular user: get permissions via role_permissions
$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
{
// Admin superuser bypass
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);
}