diff --git a/auth_keycloak.php b/auth_keycloak.php new file mode 100644 index 0000000..8be2928 --- /dev/null +++ b/auth_keycloak.php @@ -0,0 +1,90 @@ +baseUrl = rtrim(KEYCLOAK_BASE_URL, '/'); + $this->realm = KEYCLOAK_REALM; + $this->clientId = KEYCLOAK_CLIENT_ID; + $this->clientSecret = KEYCLOAK_CLIENT_SECRET; + $this->redirectUri = KEYCLOAK_REDIRECT_URI; + } + + public function getLoginUrl() { + $params = [ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'response_type' => 'code', + 'scope' => 'openid email profile' + ]; + return $this->baseUrl . '/realms/' . $this->realm . '/protocol/openid-connect/auth?' . http_build_query($params); + } + + public function getLogoutUrl() { + $params = [ + 'client_id' => $this->clientId, + 'post_logout_redirect_uri' => $this->redirectUri + ]; + return $this->baseUrl . '/realms/' . $this->realm . '/protocol/openid-connect/logout?' . http_build_query($params); + } + + public function getToken($code) { + $url = $this->baseUrl . '/realms/' . $this->realm . '/protocol/openid-connect/token'; + $fields = [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => $code, + 'redirect_uri' => $this->redirectUri + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + if ($response === false) { + error_log('Keycloak Token Error: ' . curl_error($ch)); + curl_close($ch); + return null; + } + curl_close($ch); + + return json_decode($response, true); + } + + public function getUserInfo($accessToken) { + $url = $this->baseUrl . '/realms/' . $this->realm . '/protocol/openid-connect/userinfo'; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + if ($response === false) { + error_log('Keycloak UserInfo Error: ' . curl_error($ch)); + curl_close($ch); + return null; + } + curl_close($ch); + + return json_decode($response, true); + } + + public function verifyUser($email, $pdo) { + $stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email"); + $stmt->execute([':email' => $email]); + return $stmt->fetch(PDO::FETCH_ASSOC); + } +} diff --git a/bundledcmdb-5.0.9.zip b/bundledcmdb-5.0.9.zip deleted file mode 100644 index 401267e..0000000 Binary files a/bundledcmdb-5.0.9.zip and /dev/null differ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..75c95d8 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "ivancarlosti/bundledcmdb", + "description": "CMDB Application", + "type": "project", + "require": { + "php": "^8.2", + "aws/aws-sdk-php": "^3.300", + "ext-pdo": "*", + "ext-curl": "*", + "ext-json": "*" + }, + "autoload": { + "classmap": [ + "auth_keycloak.php", + "s3_client.php" + ] + } +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..6246128 --- /dev/null +++ b/config.php @@ -0,0 +1,23 @@ +query("DESCRIBE users"); + print_r($stmt->fetchAll(PDO::FETCH_ASSOC)); +} catch (PDOException $e) { + echo "Error: " . $e->getMessage(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..5621bb2 --- /dev/null +++ b/manifest.json @@ -0,0 +1,4 @@ +{ + "version": "5.0.9", + "author": "Ivan Carlos" +} diff --git a/public/asset.php b/public/asset.php new file mode 100644 index 0000000..fdc3f1f --- /dev/null +++ b/public/asset.php @@ -0,0 +1,395 @@ + PDO::ERRMODE_EXCEPTION] + ); +} catch (PDOException $e) { + die("DB Connection failed: " . $e->getMessage()); +} + +require_once '../s3_client.php'; +$s3 = new S3Client(); + +// --- Helper functions (Local DB & S3) --- +function get_row($pdo, $table, $id, $company) +{ + $stmt = $pdo->prepare("SELECT * FROM `$table` WHERE Id = :id AND company = :company"); + $stmt->execute([':id' => $id, ':company' => $company]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: []; +} + +function update_row($pdo, $table, $id, $data, $company) +{ + if (empty($data)) + return; + $set = []; + $params = [':id' => $id, ':company' => $company]; + foreach ($data as $col => $val) { + $set[] = "`$col` = :$col"; + $params[":$col"] = $val; + } + $sql = "UPDATE `$table` SET " . implode(', ', $set) . " WHERE Id = :id AND company = :company"; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); +} + +function get_files($pdo, $table, $id) +{ + $stmt = $pdo->prepare("SELECT * FROM device_files WHERE device_id = :id AND device_table = 'assets'"); + $stmt->execute([':id' => $id]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); +} + +function upload_file($pdo, $s3, $table, $id, $file) +{ + $fileName = $file['name']; + $tmpName = $file['tmp_name']; + $mime = $file['type']; + $size = $file['size']; + + // Generate unique key + $key = "uploads/$table/$id/" . uniqid() . '_' . $fileName; + + $result = $s3->uploadFile($tmpName, $key, $mime); + + if ($result['success']) { + $stmt = $pdo->prepare("INSERT INTO device_files (device_id, device_table, file_path, file_name, mime_type, size) VALUES (:did, :dtab, :path, :name, :mime, :size)"); + $stmt->execute([ + ':did' => $id, + ':dtab' => 'assets', + ':path' => $key, + ':name' => $fileName, + ':mime' => $mime, + ':size' => $size + ]); + return ['success' => true]; + } + return $result; +} + +function delete_file($pdo, $s3, $fileId) +{ + $stmt = $pdo->prepare("SELECT * FROM device_files WHERE id = :id"); + $stmt->execute([':id' => $fileId]); + $file = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($file) { + if ($s3->deleteFile($file['file_path'])) { + $del = $pdo->prepare("DELETE FROM device_files WHERE id = :id"); + $del->execute([':id' => $fileId]); + return true; + } + } + return false; +} + +// --- Handle file actions --- +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($role === 'user') { + die('Access Denied: Read-only user.'); + } + if (isset($_FILES['new_file'])) { + $file = $_FILES['new_file']; + if ($file['error'] === UPLOAD_ERR_OK) { + $result = upload_file($pdo, $s3, $table, $recordId, $file); + if (!$result['success']) { + die("S3 Upload Failed. Code: " . $result['code'] . " | Message: " . htmlspecialchars($result['message'])); + } + } else { + die("File upload error code: " . $file['error']); + } + header("Location: asset.php?id=" . urlencode((string) $recordId)); + exit(); + } + if (isset($_POST['delete_file'])) { + delete_file($pdo, $s3, $_POST['delete_file']); + header("Location: asset.php?id=" . urlencode((string) $recordId)); + exit(); + } +} + +// --- Row data --- +$row = get_row($pdo, $table, $recordId, $company); +if ($role === 'user' && ($row['UserEmail'] ?? '') !== $currentUserEmail) { + die('Access Denied: You do not own this asset.'); +} +$files = get_files($pdo, $table, $recordId); + +$companyUsers = []; +if ($role === 'admin' || $role === 'manager' || $role === 'superadmin') { + // Fetch all users for this company to populate the dropdown + $uStmt = $pdo->prepare("SELECT email FROM users WHERE company = :comp ORDER BY email ASC"); + $uStmt->execute([':comp' => $company]); + $companyUsers = $uStmt->fetchAll(PDO::FETCH_COLUMN); +} +function escape($v) +{ + return htmlspecialchars((string) ($v ?? ''), ENT_QUOTES, 'UTF-8'); +} + +// Serial number for title/h2; fall back to record id if empty +$serial = trim((string) ($row['SN'] ?? '')); +$serialForTitle = $serial !== '' ? $serial : (string) $recordId; + +// --- Columns config --- +// Reordered: Term first, then LastSeen, then UserEmail, then rest. +$columns = [ + 'Id', + 'UUID', + 'SN', + 'OS', + 'OSVersion', + 'Hostname', + 'Mobile', + 'Manufacturer', + 'Term', + 'LastSeen', + 'UserEmail', + 'BYOD', + 'Status', + 'Warranty', + 'Asset', + 'PurchaseDate', + 'CypherID', + 'CypherKey', + 'Notes' +]; + +// Insert the new read-only, view-only columns immediately after CypherKey (or append if not present) +$newReadOnlyCols = [ + 'CPUs', + 'HDs', + 'HDsTypes', + 'HDsSpacesGB', + 'NetworkAdapters', + 'MACAddresses', + 'ESETComponents', + 'PrimaryLocalIP', + 'PrimaryRemoteIP' +]; +$idx = array_search('CypherKey', $columns, true); +if ($idx !== false) { + array_splice($columns, $idx + 1, 0, $newReadOnlyCols); +} else { + // Non-admin or CypherKey absent: still show these new fields + $columns = array_merge($columns, $newReadOnlyCols); +} + +$hidden = ['Id']; +$editable = ['UserEmail', 'Status', 'Warranty', 'Asset', 'PurchaseDate', 'BYOD']; + +// Role-based editability +if ($role === 'user') { + $editable = []; +} else { + // Manager, Admin, Superadmin can edit Notes + if (in_array($role, ['manager', 'admin', 'superadmin'])) { + $editable[] = 'Notes'; + } + // Admin, Superadmin can edit Cypher fields + if (in_array($role, ['admin', 'superadmin'])) { + $editable[] = 'CypherID'; + $editable[] = 'CypherKey'; + } +} +// Mark the requested fields as read-only (view-only) +$readonly = array_merge( + ['Hostname'], + [ + 'CPUs', + 'HDs', + 'HDsTypes', + 'HDsSpacesGB', + 'NetworkAdapters', + 'MACAddresses', + 'ESETComponents', + 'PrimaryLocalIP', + 'PrimaryRemoteIP' + ] +); + +$display = array_filter($columns, fn($c) => !in_array($c, $hidden, true)); + +$status_options = ["In Use", "In Stock", "In Repair", "Replaced", "Decommissioned", "Lost or Stolen"]; +?> + + + + + + CMDB Row Details (SN #<?php echo escape($serialForTitle); ?>) + + + + + ← Back to CMDB Company: +

CMDB Row Details (SN #)

+ +
+ + + + + + + + + + + + +
+ 1 ? 's' : '') : 'No files'; + + } elseif ($col === 'SN') { + echo escape($value); + + } elseif (in_array($col, $editable, true)) { + + if ($col === 'BYOD') { + $v = strtolower(trim((string) $value)); + $isTrue = in_array($v, ['true', '1', 'yes', 'on'], true); + ?> + + + + + + + + + + + + +
+ +
+ + + +
+
+ + + +
+

Upload New File

+
+ + +
+
+ + + +
+

Existing Files

+
+ getPresignedUrl($f['file_path']); + $title = $f['file_name']; + $delete = $f['id']; + ?> +
+ + +
+ + +
+ +
+ +
No files found.
+ +
+
+ + + \ No newline at end of file diff --git a/public/export.php b/public/export.php new file mode 100644 index 0000000..27e1faa --- /dev/null +++ b/public/export.php @@ -0,0 +1,159 @@ + PDO::ERRMODE_EXCEPTION] + ); +} catch (PDOException $e) { + die("DB Connection failed: " . $e->getMessage()); +} + +// Build Query +$whereClauses = []; +$params = []; + +if ($search_field !== '' && $search_text !== '') { + $whereClauses[] = "`$search_field` LIKE :searchText"; + $params[':searchText'] = '%' . $search_text . '%'; +} + +// Always filter by company +$whereClauses[] = "`company` = :company"; +$params[':company'] = $company; + +$whereSql = ''; +if (!empty($whereClauses)) { + $whereSql = 'WHERE ' . implode(' AND ', $whereClauses); +} + +// Sorting +$orderSql = ''; +if ($sort_by !== '' && in_array($sort_by, $columns_to_export, true)) { + $orderSql = "ORDER BY `$sort_by` " . ($sort_dir === 'desc' ? 'DESC' : 'ASC'); +} else { + $orderSql = "ORDER BY Id DESC"; +} + +// Fetch All Rows +$sql = "SELECT * FROM `$userTableName` $whereSql $orderSql"; +$stmt = $pdo->prepare($sql); +$stmt->execute($params); +$allRows = $stmt->fetchAll(PDO::FETCH_ASSOC); + +// Send headers to prompt download as CSV file +header('Content-Type: text/csv; charset=UTF-8'); +header('Content-Disposition: attachment; filename="cmdb_export_' . date('Ymd_His') . '.csv"'); +header('Cache-Control: no-cache, no-store, must-revalidate'); +header('Pragma: no-cache'); +header('Expires: 0'); + +$output = fopen('php://output', 'w'); + +// Write UTF-8 BOM for Excel compatibility +fwrite($output, "\xEF\xBB\xBF"); + +// Write CSV header row +fputcsv($output, $columns_to_export); + +// Write all rows +foreach ($allRows as $row) { + $exportRow = []; + foreach ($columns_to_export as $colName) { + $val = $row[$colName] ?? ''; + if (is_array($val)) { + $val = implode('; ', $val); // Flatten arrays if any + } + $exportRow[] = $val; + } + fputcsv($output, $exportRow); +} + +fclose($output); +exit(); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..453229a --- /dev/null +++ b/public/index.php @@ -0,0 +1,123 @@ +getLogoutUrl()); + exit(); +} + +// Handle Keycloak Callback +if (isset($_GET['code'])) { + $tokenData = $keycloak->getToken($_GET['code']); + + if ($tokenData && isset($tokenData['access_token'])) { + $userInfo = $keycloak->getUserInfo($tokenData['access_token']); + + if ($userInfo && isset($userInfo['email'])) { + $email = $userInfo['email']; + + // Verify user in MariaDB + try { + $pdo = new PDO( + "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", + DB_USER, + DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); + } catch (PDOException $e) { + die("DB Connection failed: " . $e->getMessage()); + } + + $user = $keycloak->verifyUser($email, $pdo); + + if ($user) { + $company = $user['company'] ?? ''; + $role = $user['role'] ?? 'user'; + + $_SESSION['user_email'] = $email; + $_SESSION['company'] = $company; + $_SESSION['role'] = $role; + + header('Location: main.php'); + exit(); + } else { + $message = 'Access Denied: User not found in authorized list.'; + } + } else { + $message = 'Failed to retrieve user information from Keycloak.'; + } + } else { + $message = 'Failed to authenticate with Keycloak.'; + } +} + +// If already logged in, redirect to main +if (isset($_SESSION['user_email'])) { + header('Location: main.php'); + exit(); +} + +// If no code and not logged in, show login page or redirect +// For better UX, we can show a "Login with SSO" button or auto-redirect. +// Let's show a simple page with a button to avoid infinite loops if configuration is wrong. +$loginUrl = $keycloak->getLoginUrl(); + +?> + + + + + CMDB - Login + + + + + + +
+

CMDB - Login

+ +

+ + +

Please sign in using your company account.

+ Sign in with SSO +
+ + + \ No newline at end of file diff --git a/public/logout.php b/public/logout.php new file mode 100644 index 0000000..4ecf494 --- /dev/null +++ b/public/logout.php @@ -0,0 +1,9 @@ + PDO::ERRMODE_EXCEPTION] + ); + $stmt = $pdo->query("SELECT DISTINCT company FROM assets ORDER BY company ASC"); + $allCompanies = $stmt->fetchAll(PDO::FETCH_COLUMN); + } catch (PDOException $e) { + die("DB Connection failed: " . $e->getMessage()); + } + + // Handle switch action + if (isset($_POST['switch_company']) && in_array($_POST['switch_company'], $allCompanies)) { + $_SESSION['company'] = $_POST['switch_company']; + header("Location: main.php"); + exit(); + } +} + +$company = $_SESSION['company'] ?? ''; +if ($company === '') { + if ($role === 'superadmin' && !empty($allCompanies)) { + // Auto-select first company if none selected + $company = $allCompanies[0]; + $_SESSION['company'] = $company; + } else { + die('No company assigned in session.'); + } +} +$userTableName = 'assets'; // Fixed table name +$role = $_SESSION['role'] ?? 'user'; +$currentUserEmail = $_SESSION['user_email'] ?? ''; +$perPage = 25; +$page = (isset($_GET['page']) && is_numeric($_GET['page']) && $_GET['page'] > 0) ? intval($_GET['page']) : 1; +// Sorting +$sort_by = $_GET['sort_by'] ?? ''; +$sort_dir = strtolower($_GET['sort_dir'] ?? 'asc'); +$sort_dir = in_array($sort_dir, ['asc','desc'], true) ? $sort_dir : 'asc'; +// Columns to fetch from API (Term before UserEmail) +$columns_to_show = [ + 'Id','UUID','SN','OS','OSVersion','Hostname','Mobile','Manufacturer', + 'Term','UserEmail','BYOD','Status','Warranty','Asset','PurchaseDate', + 'CypherID','CypherKey' +]; +// Columns editable in this grid +$columns_editable = ['UserEmail','Status','Warranty','Asset','PurchaseDate','BYOD']; +// Columns read-only in this grid +$columns_readonly = ['Hostname']; + + +// Columns hidden in this grid (but still fetched) +$columns_hidden = ['Id','UUID','CypherID','CypherKey','OSVersion','Mobile']; +// Visible columns in this grid (Term will appear before UserEmail here) +$columns_visible = array_values(array_diff($columns_to_show, $columns_hidden)); + +if ($role === 'user') { + $columns_editable = []; + $columns_readonly = $columns_visible; +} +$fields_param = implode(',', $columns_to_show); +// Hardcoded Status options +$status_options = ["In Use","In Stock","In Repair","Replaced","Decommissioned","Lost or Stolen"]; +// Search/filter +$search_field = $_GET['search_field'] ?? ''; +$search_text = $_GET['search_text'] ?? ''; +$filterParamStr = ''; +if ($search_field !== '' && $search_text !== '') { + $filterParamStr = '&where=(' . rawurlencode($search_field) . ',like,' . rawurlencode('%' . $search_text . '%') . ')'; +} +// Helper: DB Connection +try { + $pdo = new PDO( + "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", + DB_USER, + DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); +} catch (PDOException $e) { + die("DB Connection failed: " . $e->getMessage()); +} + +// Build Query +$whereClauses = []; +$params = []; + +if ($search_field !== '' && $search_text !== '') { + $whereClauses[] = "`$search_field` LIKE :searchText"; + $params[':searchText'] = '%' . $search_text . '%'; +} + +if ($role === 'user') { + $whereClauses[] = "`UserEmail` = :currentUserEmail"; + $params[':currentUserEmail'] = $currentUserEmail; +} + +// Always filter by company +$whereClauses[] = "`company` = :company"; +$params[':company'] = $company; + +$whereSql = ''; +if (!empty($whereClauses)) { + $whereSql = 'WHERE ' . implode(' AND ', $whereClauses); +} + +// Count Total +$countSql = "SELECT COUNT(*) FROM `$userTableName` $whereSql"; +$stmt = $pdo->prepare($countSql); +$stmt->execute($params); +$totalRows = $stmt->fetchColumn(); +$totalPages = $perPage > 0 ? (int)ceil($totalRows / $perPage) : 1; + +// Sorting +$orderSql = ''; +if ($sort_by !== '' && in_array($sort_by, $columns_to_show, true)) { + $orderSql = "ORDER BY `$sort_by` " . ($sort_dir === 'desc' ? 'DESC' : 'ASC'); +} else { + // Default sort + $orderSql = "ORDER BY Id DESC"; +} + +// Pagination +$offset = ($page - 1) * $perPage; +$limitSql = "LIMIT :offset, :limit"; + +// Fetch Rows +$sql = "SELECT * FROM `$userTableName` $whereSql $orderSql $limitSql"; +$stmt = $pdo->prepare($sql); +foreach ($params as $k => $v) { + $stmt->bindValue($k, $v); +} +$stmt->bindValue(':offset', $offset, PDO::PARAM_INT); +$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT); +$stmt->execute(); +$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + +$companyUsers = []; +if ($role === 'admin' || $role === 'manager' || $role === 'superadmin') { + // Fetch all users for this company to populate the dropdown + // We need to query the 'users' table. + $uStmt = $pdo->prepare("SELECT email FROM users WHERE company = :comp ORDER BY email ASC"); + $uStmt->execute([':comp' => $company]); + $companyUsers = $uStmt->fetchAll(PDO::FETCH_COLUMN); +} + +function escape($text) { + return htmlspecialchars((string)$text, ENT_QUOTES, 'UTF-8'); +} + +function count_files_in_term($row, $pdo, $tableName) { + $id = $row['Id'] ?? 0; + $stmt = $pdo->prepare("SELECT COUNT(*) FROM device_files WHERE device_id = :id AND device_table = 'assets'"); + $stmt->execute([':id' => $id]); + return $stmt->fetchColumn(); +} + +// Preserve query params for pagination links +$queryParams = $_GET; +unset($queryParams['page']); +$queryFilterStr = http_build_query($queryParams); +$paginationSuffix = $queryFilterStr ? '&' . $queryFilterStr : ''; +$startRecord = $totalRows > 0 ? (($page - 1) * $perPage) + 1 : 0; +$endRecord = ($page * $perPage) > $totalRows ? $totalRows : ($page * $perPage); + +// Helper to build sorted header links and arrow +function sort_link($col, $current_by, $current_dir) { + $params = $_GET; + $params['sort_by'] = $col; + $params['sort_dir'] = ($current_by === $col && strtolower($current_dir) === 'asc') ? 'desc' : 'asc'; + $qs = http_build_query($params); + return '?' . $qs; +} +function sort_arrow($col, $current_by, $current_dir) { + if ($col !== $current_by) return ''; + return strtolower($current_dir) === 'asc' ? '▲' : '▼'; +} +?> + + + + CMDB Company: <?php echo escape($company); ?> + + + + +

CMDB Company:

+

Signed in as: ()

+ + +
+
+ + + +
+
+ +
+
+ + + + + + Clear +
+
+ + + + + + + + + + + + + +
+ +
+
+ Showing to of records +
+
+ + + + + + + + + + + $row): ?> + + + + + + + + + + +
+ + + + +
+ ' . $label . ''; + } elseif ($col === 'Term') { + $fileCount = count_files_in_term($row, $pdo, $userTableName); + echo ''; + echo $fileCount > 0 ? ($fileCount . ' file' . ($fileCount > 1 ? 's' : '')) : 'No files'; + echo ''; + } elseif (in_array($col, $columns_editable, true)) { + if ($col === 'BYOD') { + $v = strtolower(trim((string)$value)); + $isTrue = in_array($v, ['true','1','yes','on'], true); + ?> + + + + + + + + + + + + +
+
+ +
+ + + + diff --git a/public/save_row.php b/public/save_row.php new file mode 100644 index 0000000..5917fcc --- /dev/null +++ b/public/save_row.php @@ -0,0 +1,235 @@ + PDO::ERRMODE_EXCEPTION] + ); +} catch (PDOException $e) { + die("DB Connection failed: " . $e->getMessage()); +} + +// GET current row (before) to compare +function get_row($pdo, $table, $pk, $company) +{ + $stmt = $pdo->prepare("SELECT * FROM `$table` WHERE Id = :id AND company = :company"); + $stmt->execute([':id' => $pk, ':company' => $company]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: []; +} + +// PATCH only certain fields +function update_row($pdo, $table, $pk, $data, $company) +{ + if (empty($data)) + return ['ok' => true]; + $set = []; + $params = [':id' => $pk, ':company' => $company]; + foreach ($data as $col => $val) { + $set[] = "`$col` = :$col"; + $params[":$col"] = $val; + } + $sql = "UPDATE `$table` SET " . implode(', ', $set) . " WHERE Id = :id AND company = :company"; + try { + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + return ['ok' => true]; + } catch (PDOException $e) { + return ['error' => $e->getMessage()]; + } +} + +// INSERT a single audit log +function insert_log($pdo, $email, $field, $newValue, $uuid, $dateTimeIsoUtc) +{ + // Skip logging for now or implement local log table + return ['ok' => true]; +} + +// Normalize payload similarly to your grid flow +function normalize_update_payload(array $input): array +{ + $updateData = $input; + // Remove PK/immutable from update set + unset($updateData['Id']); + + // Email: optional, but if filled must be valid; blank -> null + if (array_key_exists('UserEmail', $updateData)) { + $emailVal = trim((string) ($updateData['UserEmail'] ?? '')); + if ($emailVal !== '' && !filter_var($emailVal, FILTER_VALIDATE_EMAIL)) { + $updateData['**invalid_email**'] = $emailVal; + } else { + $updateData['UserEmail'] = ($emailVal === '') ? null : $emailVal; + } + } + + // BYOD boolean normalization + if (array_key_exists('BYOD', $updateData)) { + $val = strtolower(trim((string) $updateData['BYOD'])); + $updateData['BYOD'] = in_array($val, ['true', '1', 'on', 'yes'], true) ? 1 : 0; + } + + // Optional date/text fields: empty string -> null + foreach (['Warranty', 'PurchaseDate', 'Asset'] as $field) { + if (array_key_exists($field, $updateData)) { + $v = $updateData[$field]; + if ($v === '' || $v === null) { + $updateData[$field] = null; + } elseif (in_array($field, ['Warranty', 'PurchaseDate'], true)) { + // Normalize to YYYY-MM-DD if parseable; else null + $ts = strtotime((string) $v); + $updateData[$field] = ($ts !== false) ? date('Y-m-d', $ts) : null; + } + } + } + + return $updateData; +} + +// Robust equality with normalization (empty string ~ null, boolean-ish strings, numeric strings) +function values_equal($a, $b): bool +{ + $boolMap = ['true' => true, 'false' => false, '1' => true, '0' => false, 'yes' => true, 'no' => false]; + if (is_string($a) && array_key_exists(strtolower($a), $boolMap)) + $a = $boolMap[strtolower($a)]; + if (is_string($b) && array_key_exists(strtolower($b), $boolMap)) + $b = $boolMap[strtolower($b)]; + + if (is_string($a) && is_numeric($a)) + $a = $a + 0; + if (is_string($b) && is_numeric($b)) + $b = $b + 0; + + // Treat '' and null as equal + if ($a === '' && $b === null) + return true; + if ($b === '' && $a === null) + return true; + + if ((is_array($a) || is_object($a)) || (is_array($b) || is_object($b))) { + return json_encode($a, JSON_UNESCAPED_SLASHES) === json_encode($b, JSON_UNESCAPED_SLASHES); + } + return $a === $b; +} + +// ----------------- Build and apply update ----------------- + +// Compute which fields are allowed to be edited on this page +$editable = ['UserEmail', 'Status', 'Warranty', 'Asset', 'PurchaseDate', 'BYOD']; +$role = $_SESSION['role'] ?? 'user'; + +if ($role !== 'user') { + // Manager, Admin, Superadmin can edit Notes + if (in_array($role, ['manager', 'admin', 'superadmin'])) { + $editable[] = 'Notes'; + } + // Admin, Superadmin can edit Cypher fields + if (in_array($role, ['admin', 'superadmin'])) { + $editable[] = 'CypherID'; + $editable[] = 'CypherKey'; + } +} + +// Start from posted row; keep only editable keys +$incoming = []; +foreach ($editable as $k) { + if (array_key_exists($k, $row)) { + $incoming[$k] = $row[$k]; + } +} + +// Normalize incoming values (email/boolean/dates/empties) +$updateData = normalize_update_payload($incoming); + +// Abort on invalid email (mirror save_rows.php behavior) +if (isset($updateData['**invalid_email**'])) { + header("Location: asset.php?id=" . urlencode((string) $rowId)); + exit(); +} + +// If nothing submitted, return +if (empty($updateData)) { + header("Location: asset.php?id=" . urlencode((string) $rowId)); + exit(); +} + +// Fetch current state +$before = get_row($pdo, $userTableName, $rowId, $company); +if (empty($before)) { + // On fetch error, just return to the page (or render an error if preferred) + header("Location: asset.php?id=" . urlencode((string) $rowId)); + exit(); +} + +// Determine actual changes vs DB +$changedPayload = []; +$changedFields = []; +foreach ($updateData as $fieldName => $newVal) { + $oldVal = $before[$fieldName] ?? null; + if (!values_equal($oldVal, $newVal)) { + $changedPayload[$fieldName] = $newVal; + $changedFields[$fieldName] = $newVal; + } +} + +// If no changes, redirect back +if (empty($changedPayload)) { + header("Location: asset.php?id=" . urlencode((string) $rowId)); + exit(); +} + +// Apply PATCH with only changed fields +$result = update_row($pdo, $userTableName, $rowId, $changedPayload, $company); +if (isset($result['error'])) { + // If update failed, just go back; you can improve UX with a query param or flash + header("Location: asset.php?id=" . urlencode((string) $rowId)); + exit(); +} + +// Write one log per changed field (skip sensitive values if needed) +$nowUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); +foreach ($changedFields as $fieldName => $newValue) { + if (in_array($fieldName, ['CypherKey'], true)) { + // Skip logging sensitive secrets if desired + continue; + } + insert_log($pdo, $userEmail, $fieldName, $newValue, $uuidForLog, $nowUtc); +} + +// Done +header("Location: asset.php?id=" . urlencode((string) $rowId)); +exit(); diff --git a/public/save_rows.php b/public/save_rows.php new file mode 100644 index 0000000..a82ad3f --- /dev/null +++ b/public/save_rows.php @@ -0,0 +1,241 @@ + PDO::ERRMODE_EXCEPTION] + ); +} catch (PDOException $e) { + die("DB Connection failed: " . $e->getMessage()); +} + +// GET a single row by Id to compare before/after +function get_row($pdo, $table, $pk, $company) +{ + $stmt = $pdo->prepare("SELECT * FROM `$table` WHERE Id = :id AND company = :company"); + $stmt->execute([':id' => $pk, ':company' => $company]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: []; +} + +// PATCH a single row by Id +function update_row($pdo, $table, $pk, $data, $company) +{ + if (empty($data)) + return ['ok' => true]; + $set = []; + $params = [':id' => $pk, ':company' => $company]; + foreach ($data as $col => $val) { + $set[] = "`$col` = :$col"; + $params[":$col"] = $val; + } + $sql = "UPDATE `$table` SET " . implode(', ', $set) . " WHERE Id = :id AND company = :company"; + try { + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + return ['ok' => true]; + } catch (PDOException $e) { + return ['error' => $e->getMessage()]; + } +} + +// INSERT a single audit log (Optional: create a logs table if you want to keep this feature) +// For now, we'll skip logging or just log to a file/table if requested. +// The user didn't explicitly ask for a logs table migration, but NocoDB had it. +// Let's assume we skip it or log to error_log for now to save complexity, +// or create a simple logs table if we want to be thorough. +// Given the prompt "add collumns to manage S3 files", logging wasn't the main point. +// I'll comment it out or implement a simple local log. +function insert_log($pdo, $email, $field, $newValue, $uuid, $dateTimeIsoUtc) +{ + // Check if logs table exists, if not create it? Or just ignore. + // Let's just return ok to not break the flow. + return ['ok' => true]; +} + +// Normalize booleans, dates, email, and empty strings consistently with your grid behavior. +function normalize_update_payload(array $input): array +{ + $updateData = $input; + + // Remove immutable/PK fields from update payload + unset($updateData['Id']); + + // Validate email if provided + if (array_key_exists('UserEmail', $updateData)) { + $emailVal = trim((string) ($updateData['UserEmail'] ?? '')); + if ($emailVal !== '' && !filter_var($emailVal, FILTER_VALIDATE_EMAIL)) { + $updateData['__invalid_email__'] = $emailVal; + } else { + $updateData['UserEmail'] = ($emailVal === '') ? null : $emailVal; + } + } + + // Normalize BYOD to boolean if present + if (array_key_exists('BYOD', $updateData)) { + $val = strtolower((string) $updateData['BYOD']); + $updateData['BYOD'] = ($val === 'true' || $val === '1' || $val === 'on' || $val === 'yes') ? 1 : 0; + } + + // Convert empty date/asset strings to null + foreach (['Warranty', 'PurchaseDate', 'Asset'] as $field) { + if (array_key_exists($field, $updateData) && $updateData[$field] === '') { + $updateData[$field] = null; + } + } + + // Ensure immutable are not unintentionally nulled + foreach (['UUID', 'CypherID', 'CypherKey'] as $immutable) { + if (array_key_exists($immutable, $updateData) && ($updateData[$immutable] === '' || $updateData[$immutable] === null)) { + unset($updateData[$immutable]); + } + } + + return $updateData; +} + +// Strict comparison helper with type normalization for DB values +function values_equal($a, $b): bool +{ + // Normalize boolean-ish strings + $boolStrings = ['true' => true, 'false' => false, '1' => true, '0' => false, 'yes' => true, 'no' => false, '' => null]; + if (is_string($a) && array_key_exists(strtolower($a), $boolStrings)) + $a = $boolStrings[strtolower($a)]; + if (is_string($b) && array_key_exists(strtolower($b), $boolStrings)) + $b = $boolStrings[strtolower($b)]; + + // Normalize numeric strings + if (is_string($a) && is_numeric($a)) + $a = $a + 0; + if (is_string($b) && is_numeric($b)) + $b = $b + 0; + + // Treat empty string and null as equal for nullable fields + if ($a === '' && $b === null) + return true; + if ($b === '' && $a === null) + return true; + + // For arrays/objects (e.g., attachments), compare JSON representations + if ((is_array($a) || is_object($a)) || (is_array($b) || is_object($b))) { + return json_encode($a, JSON_UNESCAPED_SLASHES) === json_encode($b, JSON_UNESCAPED_SLASHES); + } + + return $a === $b; +} + +$errors = []; + +foreach ($rows as $index => $row) { + $pk = $row['Id'] ?? ''; + $uuidForLog = $row['UUID'] ?? ''; // taken from the hidden input in the form + + if (!$pk) { + $errors[] = "Missing Id in one row, skipping update."; + continue; + } + if ($uuidForLog === '') { + // Proceed but note missing UUID so you can diagnose later + $uuidForLog = ''; + } + + // Normalize incoming payload + $updateData = normalize_update_payload($row); + + // Invalid email? + if (isset($updateData['__invalid_email__'])) { + $errors[] = "Invalid email address on record Id $pk: " . htmlspecialchars($updateData['__invalid_email__']); + continue; + } + + // Which fields were submitted (touched) + $submittedFields = array_keys($updateData); + if (empty($submittedFields)) { + continue; // nothing to do + } + + // Fetch current row (Before) to compare + $before = get_row($pdo, $userTableName, $pk, $company); + if (isset($before['error'])) { + $errors[] = "Error fetching current row Id $pk: " . $before['error']; + continue; + } + + // Build minimal PATCH payload with only truly changed fields + $changedPayload = []; + $changedFields = []; + + foreach ($submittedFields as $fieldName) { + $newVal = $updateData[$fieldName]; + $oldVal = $before[$fieldName] ?? null; + + if (!values_equal($oldVal, $newVal)) { + $changedPayload[$fieldName] = $newVal; + $changedFields[$fieldName] = $newVal; + } + } + + if (empty($changedPayload)) { + // No real changes vs DB; skip patch and logging + continue; + } + + // Patch only changed fields + $result = update_row($pdo, $userTableName, $pk, $changedPayload, $company); + if (isset($result['error'])) { + $errors[] = "Error updating Id $pk: " . $result['error']; + continue; + } + + // Log only actual changed fields with current UTC timestamp and the record's UUID + $nowUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); + foreach ($changedFields as $fieldName => $newValue) { + // Optional: skip sensitive fields from logging + if (in_array($fieldName, ['CypherKey'], true)) { + continue; + } + $logResp = insert_log($pdo, $userEmail, $fieldName, $newValue, $uuidForLog, $nowUtc); + if (isset($logResp['error'])) { + $errors[] = "Log write failed for Id $pk, field $fieldName: " . $logResp['error']; + } + } +} + +if (!empty($errors)) { + echo "

Completed with notices:

Back"; + exit(); +} + +header('Location: main.php'); +exit(); diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..6aee17a --- /dev/null +++ b/public/style.css @@ -0,0 +1,248 @@ +/* ===== Global ===== */ +body { + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Open Sans","Helvetica Neue",sans-serif; + background-color: #fafafa; + color: #333; + margin: 20px; +} +h2 { font-weight: 600; color: #222; } + +a { + color: #3b82f6; + text-decoration: none; + font-weight: 600; +} +a:hover { text-decoration: underline; } + +/* ===== Buttons ===== */ +button { + background-color: #3b82f6; + color: white; + border: none; + padding: 8px 16px; + cursor: pointer; + font-weight: 600; + border-radius: 4px; + transition: background-color 0.2s ease; +} +button:hover { background-color: #2563eb; } +.header-links form button { background-color: #ef4444; } +.header-links form button:hover { background-color: #b91c1c; } + +/* ===== Search area (main.php) ===== */ +.search-container { margin-bottom: 15px; } +.search-container select, +.search-container input[type=text] { + padding: 6px 8px; + font-size: 14px; + border-radius: 4px; + border: 1px solid #ccc; + font-family: inherit; +} +.search-container form { + display: flex; + gap: 10px; + align-items: center; + max-width: 600px; + text-wrap: nowrap; +} +.record-info { + margin-bottom: 15px; + font-size: 14px; + color: #555; +} + +/* ===== Tables (shared for grid + asset details) ===== */ +table { + border-collapse: collapse; + width: 100%; + background-color: #fff; + box-shadow: 0 0 10px rgb(0 0 0 / 0.1); + border-radius: 5px; + overflow: hidden; + font-size: 13px; +} +th, td { + padding: 10px 12px; + border-bottom: 1px solid #ddd; + text-align: left; + vertical-align: middle; +} +th { + background-color: #f5f7fa; + font-weight: 600; + color: #555; + white-space: nowrap; +} +tr:nth-child(even) { background-color: #f9f9f9; } +th a { color: inherit; text-decoration: none; } +th .arrow { font-size: 11px; color: #888; margin-left: 6px; } + +/* ===== Inputs in cells ===== */ +input[type=text], +input[type=email], +input[type=date], +select { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; + font-family: inherit; + transition: border-color 0.2s ease-in-out; +} +input[type=text]:focus, +input[type=email]:focus, +input[type=date]:focus, +select:focus { + border-color: #3b82f6; + outline: none; + box-shadow: 0 0 2px rgba(59,130,246,0.6); +} + +/* ===== Pagination (main.php) ===== */ +.pagination { margin-top: 20px; font-size: 14px; } +.pagination a, .pagination span { + margin: 0 6px; + text-decoration: none; + color: #3b82f6; + font-weight: 600; +} +.pagination .current { color: #111827; font-weight: 700; } + +/* ===== Header links (main.php) ===== */ +.header-container { margin-bottom: 20px; } +.header-links a, .header-links form { + margin-right: 15px; + display: inline-block; + vertical-align: middle; +} + +/* ===== Asset page layout ===== */ +.asset-page .form-section, +.asset-page .upload-box, +.asset-page .existing-files { + max-width: 900px; + margin: 0 auto 30px; + background: #fff; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + padding: 20px; +} +.asset-page .save-section { + margin: 20px 0 30px; + text-align: center; +} + +/* File list items */ +.asset-page .file-item { + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 10px; +} +.asset-page .file-item button { + background: #ef4444; + color: #fff; + padding: 4px 10px; + font-size: 12px; + border-radius: 3px; +} +.asset-page .file-item button:hover { background: #b91c1c; } +.asset-page .upload-box button.upload { margin-left: 10px; } + +/* ===== Login page ===== */ +.login-page { + background-color: #fafafa; + color: #333; + margin: 20px; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Open Sans","Helvetica Neue",sans-serif; +} +.login-form { + max-width: 360px; + margin-top: 20px; +} +.login-form label { + display: block; + margin-bottom: 8px; + font-weight: 600; +} +.login-form input[type=email], +.login-form input[type=password] { + width: 100%; + padding: 8px 10px; + margin: 8px 0 16px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; +} +.error-msg { + color: #ef4444; + font-weight: 600; + margin-top: 8px; +} + +/* ==== Change Password Page ==== */ +.change-password-form { + max-width: 360px; + margin-top: 20px; +} +.change-password-form label { + display: block; + margin-bottom: 8px; + font-weight: 600; +} +.change-password-form input[type=password] { + width: 100%; + padding: 8px 10px; + margin: 8px 0 16px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; + font-family: inherit; +} +.success-msg { + color: #22c55e; + font-weight: 600; + margin-top: 8px; +} + +/* ==== Password Hasher Page ==== */ +.password-hasher-form { + max-width: 420px; + margin-top: 20px; +} +.password-hasher-form label { + display: block; + margin-bottom: 8px; + font-weight: 600; +} +.password-hasher-form input[type=password], +.password-hasher-form select { + width: 100%; + padding: 8px 10px; + margin: 8px 0 16px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; + font-family: inherit; +} + +/* Feedback */ +.error-msg { color: #ef4444; font-weight: 600; margin-top: 8px; } +.success-msg { color: #22c55e; font-weight: 600; margin-top: 8px; } + +/* Code block + meta info */ +.code-block { + background: #e5e7eb; + padding: 10px; + border-radius: 4px; + overflow-x: auto; + font-size: 14px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} +.meta-text { color: #555; font-size: 13px; margin-top: 6px; } diff --git a/s3_client.php b/s3_client.php new file mode 100644 index 0000000..ba9a61f --- /dev/null +++ b/s3_client.php @@ -0,0 +1,74 @@ +bucket = S3_BUCKET; + + $this->s3 = new Aws\S3\S3Client([ + 'version' => 'latest', + 'region' => S3_REGION, + 'endpoint' => S3_ENDPOINT, + 'use_path_style_endpoint' => true, + 'credentials' => [ + 'key' => S3_ACCESS_KEY, + 'secret' => S3_SECRET_KEY, + ], + ]); + } + + public function uploadFile($filePath, $key, $contentType = 'application/octet-stream') + { + try { + $result = $this->s3->putObject([ + 'Bucket' => $this->bucket, + 'Key' => ltrim($key, '/'), + 'SourceFile' => $filePath, + 'ContentType' => $contentType, + ]); + return ['success' => true, 'code' => 200, 'message' => 'OK', 'url' => $result['ObjectURL']]; + } catch (Aws\Exception\AwsException $e) { + return [ + 'success' => false, + 'code' => $e->getStatusCode(), + 'message' => $e->getMessage() + ]; + } + } + + public function deleteFile($key) + { + try { + $this->s3->deleteObject([ + 'Bucket' => $this->bucket, + 'Key' => ltrim($key, '/'), + ]); + return true; + } catch (Aws\Exception\AwsException $e) { + error_log($e->getMessage()); + return false; + } + } + + public function getPresignedUrl($key, $expiresIn = 3600) + { + try { + $cmd = $this->s3->getCommand('GetObject', [ + 'Bucket' => $this->bucket, + 'Key' => ltrim($key, '/'), + ]); + $request = $this->s3->createPresignedRequest($cmd, "+{$expiresIn} seconds"); + return (string) $request->getUri(); + } catch (Aws\Exception\AwsException $e) { + error_log($e->getMessage()); + return ''; + } + } +}