Files
itflow/functions/export.php
johnnyq fecd1cabff Credentials makeover: rebuilt credentials list with kind icons, username copy, password show/copy and live 2FA code buttons fetched on demand, where it opens, what it is for (asset, contact, vendors, licenses, services), password age pills and last used, filters for Starred, With 2FA, No 2FA, Not changed in 90 days and Shared with counts, search across notes, second link, tags, asset and contact, open straight to a credential with ?open=ID; new credential quick look with login, age, last viewed by, links with unlink, notes, open share links with turn off, and history; tabbed new/edit form with password generator (passphrase or random), strength meter, 2FA secret that accepts spaces or an otpauth:// link and shows the current code to check, copy buttons on both links, prefill from asset, contact or vendor; new Change password window that dates and logs the change; new Share window from anywhere (contact or email, 1 hour to 1 month, gone after opened once by default, link copied); same login controls on asset and contact pages and quick looks, vendor Logins tab, license page and quick look, service quick look, client overview favourites and search; credential pickers show icons and say when a credential moves from another asset or contact; client overview Passwords due for a change attention item and Active share links limited to live links; rotation report with selectable span, per client counts and oldest first; more export columns; demo data gets 2FA secrets, varied password ages and logins attached to their assets. Fixes passwords being blanked when the vault session had expired, other clients' assets, contacts and tags accepted on save, bulk star using the wrong permission, credential links to assets and contacts not checking the client, the edit or link permission, or which asset or contact an unlink was for, credentials left pointing at deleted assets and contacts, share links left open after a credential was deleted, import carrying values between rows and accepting any file type, 2FA secrets saved with spaces or lowercase giving no code, the service quick look listing credentials without credential access and repeating assets, an asset login with only a username being lost, and the API not dating new passwords. Adds functions/credential.php and js/credentials.js; removes credential_model.php and the old credential show password/OTP scripts. No DB change.
2026-10-14 23:33:29 -04:00

958 lines
42 KiB
PHP

<?php
/*
* ITFlow - Export helpers
*
* Shared plumbing behind the list page export modals:
* - a per-export column registry (label, source field, formatting, default state)
* - the column picker that every export modal renders
* - the CSV and PDF writers
*
* Handlers keep owning their own filter parsing and SQL. They hand rows over one
* at a time, in the same loop the old fputcsv() call sat in:
*
* $export = beginExport('assets', $_POST['export_assets'], "$file_name_prepend" . 'Assets', 'Assets', $filter_summary);
* while ($row = mysqli_fetch_assoc($sql)) {
* addExportRow($export, $row);
* }
* finishExport($export);
*
* A column's 'field' is just a key on the row array handed to addExportRow(), so a
* handler that needs a computed value (a prefixed number, a decrypted secret, a
* status word) sets that key on $row before the call - see getExportColumns() notes.
*/
// PDF is a read-and-print format, not a bulk transport. Past this many rows TCPDF's
// HTML table builder gets slow enough to walk into max_execution_time, so the
// handlers bounce the request back to CSV instead of timing out mid-download.
DEFINE("EXPORT_PDF_MAX_ROWS", 2000);
/*
* The column registry - one entry per export, in the order columns should appear.
*
* 'label' Column heading in the CSV / PDF.
* 'field' Key on the row array. Defaults to the column key itself.
* 'format' '' (raw), 'phone', 'money', 'number'. Only affects presentation.
* 'default' false to leave the box unticked when the modal opens. Defaults true.
* 'weight' Relative PDF column width, default 1. Bump it for long free text.
*
* Column keys are what the modal posts back, so they are whitelisted against this
* registry on the way in - an unknown key is dropped, not queried.
*/
function getExportColumns($export_type) {
$registry = [
// Clients - handler joins the primary contact and primary location
'clients' => [
'client_name' => ['label' => 'Client Name', 'weight' => 2],
'client_type' => ['label' => 'Industry'],
'client_referral' => ['label' => 'Referral'],
'client_website' => ['label' => 'Website', 'weight' => 2],
'location_name' => ['label' => 'Primary Location Name'],
'location_phone' => ['label' => 'Location Phone', 'format' => 'phone'],
'location_address' => ['label' => 'Location Address', 'weight' => 2],
'location_city' => ['label' => 'City'],
'location_state' => ['label' => 'State'],
'location_zip' => ['label' => 'Postal Code'],
'location_country' => ['label' => 'Country'],
'contact_name' => ['label' => 'Primary Contact Name'],
'contact_title' => ['label' => 'Title'],
'contact_phone' => ['label' => 'Contact Phone', 'format' => 'phone'],
'contact_extension' => ['label' => 'Extension'],
'contact_mobile' => ['label' => 'Contact Mobile', 'format' => 'phone'],
'contact_email' => ['label' => 'Contact Email', 'weight' => 2],
'client_rate' => ['label' => 'Hourly Rate', 'format' => 'money'],
'client_currency_code' => ['label' => 'Currency'],
'client_net_terms' => ['label' => 'Payment Terms', 'format' => 'number'],
'client_tax_id_number' => ['label' => 'Tax ID'],
'client_abbreviation' => ['label' => 'Abbreviation'],
],
'contacts' => [
'contact_name' => ['label' => 'Name', 'weight' => 2],
'contact_title' => ['label' => 'Title'],
'contact_department' => ['label' => 'Department'],
'contact_email' => ['label' => 'Email', 'weight' => 2],
'contact_phone' => ['label' => 'Phone', 'format' => 'phone'],
'contact_extension' => ['label' => 'Ext'],
'contact_mobile' => ['label' => 'Mobile', 'format' => 'phone'],
'location_name' => ['label' => 'Location'],
],
// location_primary_text, contact_name and the counts are built by the handler's query
'locations' => [
'location_name' => ['label' => 'Name', 'weight' => 2],
'location_primary_text' => ['label' => 'Primary'],
'location_description' => ['label' => 'Description', 'weight' => 3],
'location_address' => ['label' => 'Address', 'weight' => 2],
'location_city' => ['label' => 'City'],
'location_state' => ['label' => 'State'],
'location_zip' => ['label' => 'Postal Code'],
'location_country' => ['label' => 'Country', 'default' => false],
'location_phone' => ['label' => 'Phone', 'format' => 'phone'],
'location_hours' => ['label' => 'Hours'],
'contact_name' => ['label' => 'On-site Contact'],
'contact_count' => ['label' => 'People', 'format' => 'number', 'default' => false],
'asset_count' => ['label' => 'Assets', 'format' => 'number', 'default' => false],
'location_notes' => ['label' => 'Access Notes', 'weight' => 3, 'default' => false],
'client_name' => ['label' => 'Client', 'default' => false],
],
'vendors' => [
'vendor_name' => ['label' => 'Name', 'weight' => 2],
'vendor_description' => ['label' => 'Description', 'weight' => 3],
'vendor_tags' => ['label' => 'Tags', 'default' => false],
'vendor_phone' => ['label' => 'Support Phone', 'format' => 'phone'],
'vendor_extension' => ['label' => 'Extension', 'default' => false],
'vendor_email' => ['label' => 'Support Email', 'weight' => 2],
'vendor_website' => ['label' => 'Website', 'weight' => 2],
'vendor_hours' => ['label' => 'Hours'],
'vendor_sla' => ['label' => 'SLA', 'default' => false],
'vendor_account_number' => ['label' => 'Account Number'],
'vendor_code' => ['label' => 'PIN / Code', 'default' => false],
'vendor_contact_name' => ['label' => 'Account Manager'],
'vendor_contact_phone' => ['label' => 'Account Manager Phone', 'format' => 'phone', 'default' => false],
'vendor_contact_email' => ['label' => 'Account Manager Email', 'default' => false],
'open_case_count' => ['label' => 'Open Cases', 'format' => 'number', 'default' => false],
'vendor_notes' => ['label' => 'Notes', 'weight' => 3],
'client_name' => ['label' => 'Client', 'default' => false],
],
'assets' => [
'asset_name' => ['label' => 'Name', 'weight' => 2],
'asset_description' => ['label' => 'Description', 'weight' => 3],
'asset_type' => ['label' => 'Type'],
'asset_make' => ['label' => 'Make'],
'asset_model' => ['label' => 'Model'],
'asset_serial' => ['label' => 'Serial Number'],
'asset_os' => ['label' => 'Operating System', 'weight' => 2],
'asset_purchase_date' => ['label' => 'Purchase Date'],
'asset_warranty_expire' => ['label' => 'Warranty Expire'],
'asset_install_date' => ['label' => 'Install Date'],
'contact_name' => ['label' => 'Assigned To'],
'location_name' => ['label' => 'Location'],
'asset_physical_location' => ['label' => 'Physical Location'],
'asset_notes' => ['label' => 'Notes', 'weight' => 3],
// Available from the handler's join but not exported historically
'client_name' => ['label' => 'Client', 'default' => false],
'interface_ip' => ['label' => 'Primary IP', 'default' => false],
'interface_mac' => ['label' => 'Primary MAC', 'default' => false],
],
'asset_interfaces' => [
'interface_name' => ['label' => 'Name'],
'interface_description' => ['label' => 'Description', 'weight' => 3],
'interface_type' => ['label' => 'Type'],
'interface_mac' => ['label' => 'MAC'],
'interface_ip' => ['label' => 'IP'],
'interface_nat_ip' => ['label' => 'NAT IP'],
'interface_ipv6' => ['label' => 'IPv6', 'weight' => 2],
'network_name' => ['label' => 'Network'],
],
'networks' => [
'network_name' => ['label' => 'Name', 'weight' => 2],
'network_description' => ['label' => 'Description', 'weight' => 3],
'network_vlan' => ['label' => 'VLAN'],
'network' => ['label' => 'Network (CIDR)'],
'network_gateway' => ['label' => 'Gateway'],
'network_dhcp_range' => ['label' => 'IP Range'],
'network_primary_dns' => ['label' => 'Primary DNS'],
'network_secondary_dns' => ['label' => 'Secondary DNS'],
],
'network_ips' => [
'ip_address' => ['label' => 'IP Address'],
'ip_hostname' => ['label' => 'Hostname', 'weight' => 2],
'ip_description' => ['label' => 'Description', 'weight' => 3],
// Available from the handler's join but not exported by default -
// the export is always scoped to one network of one client
'network_name' => ['label' => 'Network', 'default' => false],
'client_name' => ['label' => 'Client', 'default' => false],
],
'certificates' => [
'certificate_name' => ['label' => 'Name', 'weight' => 2],
'certificate_description' => ['label' => 'Description', 'weight' => 3],
'certificate_domain' => ['label' => 'Domain', 'weight' => 2],
'certificate_issued_by' => ['label' => 'Issuer', 'weight' => 2],
'certificate_expire' => ['label' => 'Expiration Date'],
],
'domains' => [
'domain_name' => ['label' => 'Domain', 'weight' => 2],
'domain_description' => ['label' => 'Description', 'weight' => 3],
// The columns hold vendor IDs; the handler aliases the joined names
'domain_registrar' => ['label' => 'Registrar', 'field' => 'domain_registrar_name'],
'domain_webhost' => ['label' => 'Web Host', 'field' => 'domain_webhost_name'],
'domain_expire' => ['label' => 'Expiration Date'],
],
// Defaults match what this export has always emitted, secrets included.
// The handler decrypts into credential_username / credential_password.
'credentials' => [
'credential_name' => ['label' => 'Name', 'weight' => 2],
'credential_description' => ['label' => 'Description', 'weight' => 3],
'credential_username' => ['label' => 'Username'],
'credential_password' => ['label' => 'Password'],
'credential_otp_secret' => ['label' => 'TOTP'],
'credential_uri' => ['label' => 'URI', 'weight' => 2],
'credential_uri_2' => ['label' => 'URI 2', 'default' => false],
'credential_tags' => ['label' => 'Tags', 'default' => false],
'asset_name' => ['label' => 'Asset', 'default' => false],
'contact_name' => ['label' => 'Contact', 'default' => false],
'credential_password_changed' => ['label' => 'Password Changed', 'default' => false],
'credential_note' => ['label' => 'Notes', 'weight' => 3, 'default' => false],
'client_name' => ['label' => 'Client', 'default' => false],
],
// seats_used, assigned_to_assets / assigned_to_contacts and software_keys_list are built by the handler's query
'software' => [
'software_name' => ['label' => 'Name', 'weight' => 2],
'software_version' => ['label' => 'Version'],
'software_description' => ['label' => 'Description', 'weight' => 3],
'software_type' => ['label' => 'Type'],
'software_license_type' => ['label' => 'License Type'],
'software_seats' => ['label' => 'Seats', 'format' => 'number'],
'seats_used' => ['label' => 'Seats Used', 'format' => 'number'],
'software_key' => ['label' => 'Key', 'weight' => 2],
'software_keys_list' => ['label' => 'Individual Keys', 'weight' => 3, 'default' => false],
'assigned_to_assets' => ['label' => 'Assets', 'weight' => 2],
'assigned_to_contacts' => ['label' => 'Contacts', 'weight' => 2],
'vendor_name' => ['label' => 'Vendor', 'default' => false],
'software_purchase_reference' => ['label' => 'Order Ref', 'default' => false],
'software_purchase' => ['label' => 'Purchased'],
'software_expire' => ['label' => 'Expires'],
'client_name' => ['label' => 'Client', 'default' => false],
'software_notes' => ['label' => 'Notes', 'weight' => 3],
],
// ticket_number_display is prefixed by the handler
'tickets' => [
'ticket_number_display' => ['label' => 'Ticket Number'],
'ticket_priority' => ['label' => 'Priority'],
'ticket_status_name' => ['label' => 'Status'],
'ticket_subject' => ['label' => 'Subject', 'weight' => 3],
'ticket_created_at' => ['label' => 'Date Opened'],
'ticket_resolved_at' => ['label' => 'Date Resolved'],
'ticket_closed_at' => ['label' => 'Date Closed'],
'client_name' => ['label' => 'Client', 'default' => false],
'contact_name' => ['label' => 'Contact', 'default' => false],
'ticket_assigned_to' => ['label' => 'Assigned To', 'default' => false],
'ticket_category_name' => ['label' => 'Category', 'default' => false],
'ticket_billable' => ['label' => 'Billable', 'default' => false],
],
// invoice_number_display is prefix . number, built by the handler
'invoices' => [
'invoice_number_display' => ['label' => 'Invoice Number'],
'invoice_scope' => ['label' => 'Scope', 'weight' => 3],
'invoice_amount' => ['label' => 'Amount', 'format' => 'money'],
'invoice_date' => ['label' => 'Issued Date'],
'invoice_due' => ['label' => 'Due Date'],
'invoice_status' => ['label' => 'Status'],
'client_name' => ['label' => 'Client', 'weight' => 2],
'invoice_currency_code' => ['label' => 'Currency', 'default' => false],
'amount_paid' => ['label' => 'Paid', 'format' => 'money', 'default' => false],
'invoice_balance' => ['label' => 'Balance', 'format' => 'money', 'default' => false],
],
'quotes' => [
'quote_number_display' => ['label' => 'Quote Number'],
'quote_scope' => ['label' => 'Scope', 'weight' => 3],
'quote_amount' => ['label' => 'Amount', 'format' => 'money'],
'quote_date' => ['label' => 'Date'],
'quote_status' => ['label' => 'Status'],
'client_name' => ['label' => 'Client', 'weight' => 2, 'default' => false],
],
'recurring_invoices' => [
'recurring_invoice_number_display' => ['label' => 'Recurring Number'],
'recurring_invoice_scope' => ['label' => 'Scope', 'weight' => 3],
'recurring_invoice_amount' => ['label' => 'Amount', 'format' => 'money'],
'recurring_invoice_frequency_display' => ['label' => 'Frequency'],
'recurring_invoice_created_at' => ['label' => 'Date Created'],
'client_name' => ['label' => 'Client', 'weight' => 2, 'default' => false],
],
'products' => [
'product_name' => ['label' => 'Product', 'weight' => 2],
'product_description' => ['label' => 'Description', 'weight' => 3],
'product_price' => ['label' => 'Price', 'format' => 'money'],
'product_currency_code' => ['label' => 'Currency'],
'category_name' => ['label' => 'Category'],
'tax_name' => ['label' => 'Tax'],
],
'expenses' => [
'expense_date' => ['label' => 'Date'],
'expense_amount' => ['label' => 'Amount', 'format' => 'money'],
'vendor_name' => ['label' => 'Vendor', 'weight' => 2],
'expense_description' => ['label' => 'Description', 'weight' => 3],
'category_name' => ['label' => 'Category'],
'account_name' => ['label' => 'Account'],
'client_name' => ['label' => 'Client', 'weight' => 2, 'default' => false],
'expense_reference' => ['label' => 'Reference', 'default' => false],
],
'income' => [
'income_date' => ['label' => 'Date'],
'income_type' => ['label' => 'Type'],
'income_source' => ['label' => 'Source', 'weight' => 3],
'income_category' => ['label' => 'Category'],
'income_client' => ['label' => 'Client', 'weight' => 2],
'income_amount' => ['label' => 'Amount', 'format' => 'money'],
'income_currency_code' => ['label' => 'Currency'],
'income_method' => ['label' => 'Payment Method'],
'income_reference' => ['label' => 'Reference'],
'income_account' => ['label' => 'Account'],
],
'transactions' => [
'transaction_date' => ['label' => 'Date'],
'transaction_type' => ['label' => 'Type'],
'transaction_description' => ['label' => 'Description', 'weight' => 3],
'transaction_other_account' => ['label' => 'Transfer Account'],
'transaction_reference' => ['label' => 'Reference'],
'transaction_category' => ['label' => 'Category'],
'transaction_payment_method' => ['label' => 'Payment Method'],
'transaction_amount' => ['label' => 'Amount', 'format' => 'money'],
'transaction_balance' => ['label' => 'Balance', 'format' => 'money'],
],
'trips' => [
'trip_date' => ['label' => 'Date'],
'trip_purpose' => ['label' => 'Purpose', 'weight' => 3],
'trip_source' => ['label' => 'Source', 'weight' => 2],
'trip_destination' => ['label' => 'Destination', 'weight' => 2],
'trip_miles' => ['label' => 'Miles', 'format' => 'number'],
'client_name' => ['label' => 'Client', 'weight' => 2, 'default' => false],
],
// user_status_display is the status word, built by the handler
'users' => [
'user_name' => ['label' => 'Name', 'weight' => 2],
'user_email' => ['label' => 'Email', 'weight' => 2],
'role_name' => ['label' => 'Role'],
'user_status_display' => ['label' => 'Status'],
'user_created_at' => ['label' => 'Creation Date'],
],
];
return $registry[$export_type] ?? [];
}
/*
* Which columns the modal ticked, whitelisted against the registry.
* Registry order always wins, so the file layout is stable no matter what order
* the checkboxes came back in. Nothing ticked (or no picker on the form) falls
* back to the export's defaults.
*/
function resolveExportColumns($export_type) {
$available = getExportColumns($export_type);
if (empty($available)) {
return [];
}
$requested = $_POST['columns'] ?? [];
if (!is_array($requested)) {
$requested = [];
}
$selected = [];
foreach ($available as $key => $column) {
if (in_array($key, $requested, true)) {
$selected[$key] = $column;
}
}
if (empty($selected)) {
foreach ($available as $key => $column) {
if ($column['default'] ?? true) {
$selected[$key] = $column;
}
}
}
return $selected;
}
/*
* The export buttons post their format as the value of the trigger, so
* $_POST['export_assets'] is the string 'csv' or 'pdf'. Anything else is CSV.
*/
function resolveExportFormat($format) {
return ($format === 'pdf') ? 'pdf' : 'csv';
}
/*
* The gate every export handler opens on. Keying on isset() alone means any other
* field that happens to share the trigger's name fires the export - the client PDF
* pack's section checkboxes (export_assets=1, export_contacts=1, ...) did exactly
* that, and since post.php loads every handler, the first match won and streamed a
* CSV instead. Only 'csv' or 'pdf' - what renderExportButtons() posts - counts.
*/
function isExportRequest($trigger) {
return isset($_POST[$trigger]) && in_array($_POST[$trigger], ['csv', 'pdf'], true);
}
/*
* PDF is capped - see EXPORT_PDF_MAX_ROWS. Call this after the row count is known
* and before beginExport(); it redirects rather than returning on refusal.
*/
function guardExportPdfRowCount($format, $num_rows) {
if (resolveExportFormat($format) === 'pdf' && $num_rows > EXPORT_PDF_MAX_ROWS) {
flashAlert("That's " . number_format($num_rows) . " rows - too many for a PDF. Narrow the filters or export to CSV instead.", 'error');
redirect();
}
}
/*
* Presentation only. CSV keeps numbers raw so spreadsheets and importers still
* see a number; the PDF is for reading, so it gets thousands separators.
*/
function formatExportValue($value, $format, $output, $row = [], $field = '') {
// An empty field is empty, whatever its format - a blank amount is not 0.00
if ($value === null || $value === '') {
return '';
}
if ($format === 'phone') {
// Every phone column in the schema is paired with <column>_country_code,
// and the export queries all select the table wholesale, so the code is
// already in the row - it just was not reachable from here before.
return formatPhoneNumber($value, $row[$field . '_country_code'] ?? '');
}
if ($output === 'pdf' && $format === 'money') {
return number_format(floatval($value), 2);
}
if ($output === 'pdf' && $format === 'number') {
return rtrim(rtrim(number_format(floatval($value), 2), '0'), '.');
}
return $value;
}
/*
* Renders the column picker into an export modal. Drop it in the modal body:
*
* <?php renderExportColumnPicker('assets'); ?>
*
* Posts back as columns[]. Silently renders nothing for an export type that has
* no registry entry yet, so a half-converted modal still works.
*/
function renderExportColumnPicker($export_type) {
$available = getExportColumns($export_type);
if (empty($available)) {
return;
}
// Modals get appended to the DOM, so the container id has to be unique per instance
static $instance = 0;
$instance++;
$picker_id = 'exportColumns' . $instance;
?>
<div class="mb-3">
<label class="d-flex justify-content-between align-items-center">
<span>Columns</span>
<span>
<button type="button" class="btn btn-link btn-sm p-0 me-2 export-columns-all">Select all</button>
<button type="button" class="btn btn-link btn-sm p-0 export-columns-none">None</button>
</span>
</label>
<div id="<?= $picker_id ?>" class="export-column-picker border rounded p-2" style="max-height: 220px; overflow-y: auto;">
<div class="row">
<?php foreach ($available as $column_key => $column) { ?>
<div class="col-md-6">
<label class="d-block mb-1 fw-normal text-truncate" title="<?= escapeHtml($column['label']) ?>">
<input class="form-check-input" type="checkbox" name="columns[]" value="<?= $column_key ?>" <?php if ($column['default'] ?? true) { echo 'checked'; } ?>>
<?= escapeHtml($column['label']) ?>
</label>
</div>
<?php } ?>
</div>
</div>
<small class="form-text text-muted export-columns-count"></small>
</div>
<script>
(function () {
var picker = document.getElementById('<?= $picker_id ?>');
if (!picker) {
return;
}
// Everything is looked up relative to the picker's own form group, so several
// pickers on one page - or several modals in a session - never cross wires
var group = picker.parentNode;
var boxes = picker.querySelectorAll('input[name="columns[]"]');
var counter = group.querySelector('.export-columns-count');
var form = picker.closest('form');
function setAll(state) {
for (var i = 0; i < boxes.length; i++) {
boxes[i].checked = state;
}
update();
}
function update() {
var checked = picker.querySelectorAll('input[name="columns[]"]:checked').length;
counter.classList.remove('text-danger');
counter.textContent = checked + ' of ' + boxes.length + ' columns selected'
+ (checked > 10 ? ' - a lot for a PDF, CSV will read better' : '');
}
picker.addEventListener('change', update);
group.querySelector('.export-columns-all').addEventListener('click', function () { setAll(true); });
group.querySelector('.export-columns-none').addEventListener('click', function () { setAll(false); });
// An empty selection would silently fall back to the defaults server side -
// stop it here so the choice stays the user's
if (form) {
form.addEventListener('submit', function (e) {
if (picker.querySelectorAll('input[name="columns[]"]:checked').length === 0) {
e.preventDefault();
counter.textContent = 'Select at least one column to export';
counter.classList.add('text-danger');
}
});
}
update();
})();
</script>
<?php
}
/*
* Builds the export modal's data-modal-url with the page's current filters attached,
* so the list page doesn't need a hand-rolled query string per export button:
*
* data-modal-url="<?= buildExportModalUrl('modals/asset/asset_export.php', ['client_id', 'type', 'q']) ?>"
*
* Empty params are dropped and array params (tags[], status[]) survive. The return
* value is escaped for use in an HTML attribute.
*/
function buildExportModalUrl($modal_path, $names, $extra = []) {
$params = [];
foreach ($names as $name) {
if (!isset($_GET[$name]) || $_GET[$name] === '' || $_GET[$name] === []) {
continue;
}
$params[$name] = $_GET[$name];
}
// Filters the page derived rather than read straight off the query string -
// filter_header.php's canned date ranges being the main one
foreach ($extra as $name => $value) {
if ($value !== '' && $value !== null && $value !== []) {
$params[$name] = $value;
}
}
if (empty($params)) {
return escapeHtml($modal_path);
}
return escapeHtml($modal_path . '?' . http_build_query($params));
}
/*
* filter_header.php falls back to 1970-01-01 / 2099-12-31 when no date range is
* chosen, so a naive summary would claim a filter that isn't really there.
* Returns '' for the all-time case, otherwise a readable range.
*/
function formatExportDateRange($date_from, $date_to) {
$all_time_from = ($date_from === '' || $date_from === null || $date_from === '1970-01-01' || $date_from === '0000-00-00');
$all_time_to = ($date_to === '' || $date_to === null || $date_to === '2099-12-31' || $date_to === '9999-00-00');
if ($all_time_from && $all_time_to) {
return '';
}
return $date_from . ' to ' . $date_to;
}
/*
* Human-readable one-liner of the same thing, for the PDF subtitle.
*/
function summarizeExportFilters($filters) {
$parts = [];
foreach ($filters as $label => $value) {
if ($value !== '' && $value !== null) {
$parts[] = $label . ': ' . $value;
}
}
return empty($parts) ? '' : 'Filters - ' . implode(' | ', $parts);
}
/*
* The tabbed shell every export modal uses - Filters on the first pane, Columns on the
* second, same pill nav as the add-client modal. Ids are per-instance because modals are
* appended to the DOM rather than living in the page.
*
* Sits between the modal header and the form:
*
* <?php exportTabsNav(); ?>
* <form ...>
* <div class="modal-body">
* <?php exportTabsFiltersOpen(); ?>
* ...filter controls...
* <?php exportTabsColumns('assets'); ?>
* </div>
*
* exportTabsColumns() closes the filters pane, renders the picker in the second pane and
* closes the tab content, so the modal body needs nothing else.
*/
function exportTabsId() {
static $instance = 0;
static $current = '';
if (func_num_args() > 0 && func_get_arg(0) === 'next') {
$instance++;
$current = 'exportTab' . $instance;
}
return $current;
}
function exportTabsNav($filters_label = 'Filters') {
$id = exportTabsId('next');
?>
<ul class="modal-header nav nav-pills nav-justified">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="pill" href="#<?= $id ?>-filters"><?= escapeHtml($filters_label) ?></a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#<?= $id ?>-columns">Columns</a>
</li>
</ul>
<?php
}
function exportTabsFiltersOpen() {
$id = exportTabsId();
?>
<div class="tab-content">
<div class="tab-pane fade show active" id="<?= $id ?>-filters">
<?php
}
function exportTabsColumns($export_type) {
$id = exportTabsId();
?>
</div>
<div class="tab-pane fade" id="<?= $id ?>-columns">
<?php renderExportColumnPicker($export_type); ?>
</div>
</div>
<?php
}
/*
* Renders the CSV / PDF submit pair for an export modal footer.
* $trigger is the POST key the handler keys on, e.g. 'export_assets'.
*/
function renderExportButtons($trigger) {
?>
<button type="submit" name="<?= $trigger ?>" value="csv" class="btn btn-primary text-bold"><i class="fas fa-fw fa-file-csv me-2"></i>Download CSV</button>
<button type="submit" name="<?= $trigger ?>" value="pdf" class="btn btn-secondary text-bold"><i class="fas fa-fw fa-file-pdf me-2"></i>Download PDF</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>
<?php
}
/*
* Opens an export. $filename_base is everything before the timestamp, e.g.
* "Acme Inc-Assets"; the date and extension are appended here.
* $subtitle is the human summary of the filters in play - it prints under the
* title in the PDF and is ignored for CSV.
*/
function beginExport($export_type, $format, $filename_base, $title = '', $subtitle = '') {
$format = resolveExportFormat($format);
$columns = resolveExportColumns($export_type);
$export = [
'format' => $format,
'columns' => $columns,
'title' => $title,
'subtitle' => $subtitle,
'filename' => sanitizeFilename($filename_base . '-' . date('Y-m-d_H-i-s') . '.' . $format),
'rows' => 0,
'fp' => null,
'body' => '',
'widths' => [],
'missing' => [],
];
if ($format === 'csv') {
$export['fp'] = fopen('php://memory', 'w');
fputcsv($export['fp'], array_column($columns, 'label'), ',', '"', '\\');
} else {
// TCPDF sizes a table from the cells it sees, so every row needs the widths -
// putting them on the header alone leaves the body offset from its headings
$total_weight = 0;
foreach ($columns as $column) {
$total_weight += $column['weight'] ?? 1;
}
foreach ($columns as $column_key => $column) {
$export['widths'][$column_key] = round((($column['weight'] ?? 1) / $total_weight) * 100, 2);
}
}
return $export;
}
/*
* One row. $row is a plain associative array - normally straight out of
* mysqli_fetch_assoc(), with any handler-computed keys already set on it.
*/
function addExportRow(&$export, $row) {
$export['rows']++;
$values = [];
foreach ($export['columns'] as $column_key => $column) {
$field = $column['field'] ?? $column_key;
// A selected column whose field the query never returned would otherwise be a
// silently blank column all the way down - note it so finishExport can shout
if (!array_key_exists($field, $row)) {
$export['missing'][$field] = true;
}
$values[$column_key] = formatExportValue($row[$field] ?? '', $column['format'] ?? '', $export['format'], $row, $field);
}
if ($export['format'] === 'csv') {
fputcsv($export['fp'], array_map('escapeCsvFormula', $values), ',', '"', '\\');
return;
}
// PDF - buffer the row, the document is assembled in finishExport()
$stripe = ($export['rows'] % 2 === 0) ? ' bgcolor="#f4f4f4"' : '';
$cells = '';
foreach ($export['columns'] as $column_key => $column) {
$align = in_array($column['format'] ?? '', ['money', 'number'], true) ? 'right' : 'left';
$width = $export['widths'][$column_key] ?? 0;
// Blank cells read as a mistake in a printed table - an explicit dash doesn't
$cell = ($values[$column_key] === '') ? '-' : escapeHtml($values[$column_key]);
$cells .= '<td width="' . $width . '%" align="' . $align . '">' . $cell . '</td>';
}
$export['body'] .= '<tr' . $stripe . '>' . $cells . '</tr>';
}
/*
* A column whose field the query never selected produces a blank column rather than an
* error, which is exactly the kind of thing that ships unnoticed. Nothing is shown to
* the user - the file is still valid - but it lands in the PHP error log where a
* conversion mistake will be spotted.
*/
function reportMissingExportFields(&$export) {
if (!empty($export['missing'])) {
error_log('ITFlow export: no such field in result set for column(s): ' . implode(', ', array_keys($export['missing'])));
}
}
/*
* Streams the finished file. Sends its own headers, so nothing may be echoed
* before it; the caller still owns the logAudit() call and the exit.
*/
function finishExport(&$export) {
global $session_company_name;
reportMissingExportFields($export);
if ($export['format'] === 'csv') {
fseek($export['fp'], 0);
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="' . $export['filename'] . '";');
fpassthru($export['fp']);
fclose($export['fp']);
return $export['rows'];
}
require_once __DIR__ . '/../libs/TCPDF/tcpdf.php';
$column_count = count($export['columns']);
// Anything past a handful of columns needs the long edge
$orientation = $column_count > 5 ? 'L' : 'P';
// Shrink type as columns pile up rather than letting TCPDF wrap every cell
if ($column_count <= 6) {
$font_size = 8;
} elseif ($column_count <= 9) {
$font_size = 7;
} elseif ($column_count <= 12) {
$font_size = 6;
} else {
$font_size = 5;
}
$pdf = new TCPDF($orientation, 'mm', 'A4', true, 'UTF-8', false);
$pdf->SetCreator(PDF_CREATOR);
$pdf->SetAuthor($session_company_name);
$pdf->SetTitle($export['title']);
$pdf->SetPrintHeader(false);
$pdf->SetMargins(8, 8, 8);
$pdf->SetAutoPageBreak(true, 12);
$pdf->AddPage();
$pdf->SetFont('freeserif', '', $font_size);
// Same widths the body rows used - see beginExport()
$head = '';
foreach ($export['columns'] as $column_key => $column) {
$width = $export['widths'][$column_key] ?? 0;
$head .= '<th width="' . $width . '%">' . escapeHtml($column['label']) . '</th>';
}
$html = '
<style>
h1 { font-size: ' . ($font_size + 6) . 'pt; margin: 0; }
p.meta { font-size: ' . $font_size . 'pt; color: #555555; margin: 2px 0 0 0; }
table { width: 100%; border-collapse: collapse; }
table, th, td { border: 0.5px solid #999999; }
th { background-color: #343a40; color: #ffffff; text-align: left; font-weight: bold; padding: 3px; }
td { padding: 3px; }
thead { display: table-header-group; }
</style>
<h1>' . escapeHtml($export['title']) . '</h1>
<p class="meta">' . escapeHtml($session_company_name) . '</p>';
if (!empty($export['subtitle'])) {
$html .= '<p class="meta">' . escapeHtml($export['subtitle']) . '</p>';
}
$html .= '<p class="meta">' . $export['rows'] . ' record(s) - generated ' . date('Y-m-d H:i') . '</p><br>
<table cellspacing="0" cellpadding="2">
<thead><tr>' . $head . '</tr></thead>
<tbody>' . $export['body'] . '</tbody>
</table>';
$pdf->writeHTML($html, true, false, true, false, '');
$pdf->Output($export['filename'], 'D');
return $export['rows'];
}
/*
* ---------------------------------------------------------------------------
* Client PDF pack
* ---------------------------------------------------------------------------
*
* The sections the client "Export Data" PDF can contain, in document order.
*
* 'label' Checkbox label in the export modal.
* 'icon' Font Awesome class shown beside it.
* 'module' Permission module that owns the section - same mapping the list
* exports use. A role without read access to it never sees the
* checkbox and never gets the section, so one missing module drops a
* section rather than refusing the whole export.
* 'default' Whether the box starts ticked.
*
* The modal renders from this and the handler resolves from it, so a section
* can't be offered by one and ignored by the other.
*/
function getClientPackSections() {
return [
'contacts' => ['label' => 'Contacts', 'icon' => 'fa-users', 'module' => 'module_client', 'default' => true],
'locations' => ['label' => 'Locations', 'icon' => 'fa-map-marker-alt', 'module' => 'module_client', 'default' => true],
'vendors' => ['label' => 'Vendors', 'icon' => 'fa-building', 'module' => 'module_client', 'default' => true],
'credentials' => ['label' => 'Credentials', 'icon' => 'fa-key', 'module' => 'module_credential', 'default' => false],
'assets' => ['label' => 'Assets', 'icon' => 'fa-desktop', 'module' => 'module_support', 'default' => true],
'software' => ['label' => 'Software / Licenses', 'icon' => 'fa-cube', 'module' => 'module_support', 'default' => true],
'networks' => ['label' => 'Networks', 'icon' => 'fa-network-wired', 'module' => 'module_support', 'default' => true],
'domains' => ['label' => 'Domains', 'icon' => 'fa-globe', 'module' => 'module_support', 'default' => true],
'certificates' => ['label' => 'Certificates', 'icon' => 'fa-lock', 'module' => 'module_support', 'default' => true],
];
}
/*
* Read access per module, resolved once rather than once per section.
*/
function getClientPackSectionAccess() {
$access = [];
foreach (getClientPackSections() as $section) {
if (!isset($access[$section['module']])) {
$access[$section['module']] = lookupUserPermission($section['module']) >= 1;
}
}
return $access;
}
/*
* Resolves the posted checkboxes against the signed-in role. Returns
* ['contacts' => 1|0, ...]. A section the role can't read is 0 whatever was
* posted, so a hand-rolled POST can't pull in a section the modal never offered.
*/
function resolveClientPackSections() {
$access = getClientPackSectionAccess();
$selected = [];
foreach (getClientPackSections() as $key => $section) {
$selected[$key] = ($access[$section['module']] && !empty($_POST["include_$key"])) ? 1 : 0;
}
return $selected;
}
/*
* The section checkboxes for the client PDF pack modal, split across two columns.
* Sections the role can't read are left out entirely rather than shown and then
* silently dropped server side.
*/
function renderClientPackSections() {
$access = getClientPackSectionAccess();
$visible = [];
foreach (getClientPackSections() as $key => $section) {
if ($access[$section['module']]) {
$visible[$key] = $section;
}
}
$split = (int) ceil(count($visible) / 2);
$index = 0;
?>
<div class="row">
<div class="col-sm-6">
<?php foreach ($visible as $key => $section) { ?>
<?php if ($index === $split) { ?>
</div>
<div class="col-sm-6">
<?php } ?>
<li class="list-group-item">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="include_<?= $key ?>" name="include_<?= $key ?>" value="1" <?php if ($section['default']) { echo 'checked'; } ?>>
<label for="include_<?= $key ?>" class="form-check-label">
<i class="fas fa-fw <?= $section['icon'] ?> me-2"></i><?= escapeHtml($section['label']) ?>
</label>
</div>
</li>
<?php $index++; ?>
<?php } ?>
</div>
</div>
<?php
}