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 #)
+
+
+
+
+ ← Back to CMDB Company:
+ CMDB Row Details (SN #)
+
+
+
+
+
+
+
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:
+
+
+
+
+CMDB Company:
+Signed in as: ()
+
+
+
+
+
+
+
+
+
+
+
+
+ Showing to of records
+
+
+
+
+
+
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:
";
+ foreach ($errors as $error) {
+ echo "- " . htmlspecialchars($error) . "
";
+ }
+ echo "
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 '';
+ }
+ }
+}