Files
itflow/agent/post/file.php
johnnyq 1a613eab7b Files and documents overhaul: rebuilt files list with folder tree (counts, open/close remembered, phone collapse), current folder path and folder menu, All/Documents/Uploads tabs with counts, colour-coded type tiles, shared and portal pills, updated time and author, linked record counts, permission-gated row menus, search across every folder from the top level showing where each result lives, grid view with real previews (image thumbnails, PDF first page, document and text excerpts) and an arrow-key previewer with a Details button, drag and drop files onto the list to upload into the folder on show, bulk move/archive/restore/delete for files and documents together; document page rebuilt like the asset and contact pages with header actions (Edit, Share, PDF, Print, Rename, Move, Link, portal visibility, Archive, Delete), Linked card, folder and portal card and a History card where versions can be viewed, restored (current content kept as a version) or deleted, revision table moved to print only; new file quick look with preview, folder, uploader, share status and links; document quick look and version viewer redone; one link modal for every relation (files, documents, assets, contacts, licenses, vendors) with type tabs and multi-select, offering only the client's own unarchived, not yet linked records, old link modals now open it; upload modal with drop box, file list and size checks; new and edit document share one form with folder and client portal switch; template, rename, move, bulk move, visibility and folder add/rename/delete modals modernized with folder tree pickers; asset and contact pages show the same file and document tiles and open the file quick look; fixes: folder, contact and asset ownership checked on upload, new document, template, move and bulk move, links limited to records of the same client, linking an existing link no longer errors, deleting a folder now moves its files, documents and subfolders up instead of orphaning them, file delete confirmation no longer builds its name into inline JS, permission checks added to the upload, new document, folder, visibility and link modals, upload modal script error that skipped the size check, skipped uploads now reported, mime type read from the uploaded file, per-row link modals that broke grid view, search with quotes double escaped, document search text stored without tags and with words spaced, bulk messages with empty counts, visibility wording, file rename icon, PDF export redirecting after output and needing write access, file and document deletes now remove their links and skip missing files on disk, duplicate asset to file link handler removed; developer: new functions/links.php (relation registry, linkAdd, linkRemove, linkRemoveAll, linkedRecords, linkCandidates, linkGroupsHtml), post/link.php with link_items and unlink_item plus the legacy link and unlink names, functions/files.php gains fileTypeInfo, fileTypeTile, documentTile, folderTree, folderPath, folderSubtree, folderOptionsHtml, folderOwnedId, delete helpers, documentContentRaw and documentHtmlPurify, new agent/js/files.js, modals/link/link.php, modals/file/file.php, modals/folder/folder_delete.php and modals/document/document_form.php, removed unused document_bulk_move.php, document_add_file_relation.php and js/file_delete_modal.js
2026-10-14 20:35:41 -04:00

417 lines
15 KiB
PHP

<?php
/*
* ITFlow - GET/POST request handler for client files/uploads
*
* Linking files to assets, contacts and documents lives in post/link.php.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
/** "2 documents and 3 files" - for the bulk messages. */
function filesCountPhrase($document_count, $file_count) {
return implode(' and ', array_filter([
$document_count ? "<strong>$document_count</strong> document" . ($document_count === 1 ? '' : 's') : '',
$file_count ? "<strong>$file_count</strong> file" . ($file_count === 1 ? '' : 's') : '',
])) ?: 'nothing';
}
if (isset($_POST['upload_files'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$client_id = intval($_POST['client_id']);
$description = escapeSql($_POST['description'] ?? '');
$client_dir = "../uploads/clients/$client_id";
enforceClientAccess();
// Only the client's own folder, contact and asset
$folder_id = folderOwnedId($_POST['folder_id'] ?? 0, $client_id);
$contact_id = assetClientOwnedId('contact', $_POST['contact_id'] ?? 0, $client_id);
$asset = linkRecord('asset', $_POST['asset_id'] ?? 0);
$asset_id = $asset && intval($asset['client_id']) === $client_id ? intval($asset['id']) : 0;
if (!is_dir($client_dir)) {
mkdir($client_dir, 0755, true);
}
$allowedExtensions = [
'jpg', 'jpeg', 'gif', 'png', 'webp', 'pdf', 'txt', 'md', 'doc', 'docx',
'odt', 'csv', 'xls', 'xlsx', 'ods', 'pptx', 'odp', 'zip', 'tar', 'gz',
'msg', 'json', 'wav', 'mp3', 'ogg', 'mov', 'mp4', 'av1', 'ovpn',
'cfg', 'ps1', 'vsdx', 'drawio', 'pfx', 'pages', 'numbers', 'unf', 'unifi',
'key', 'bat', 'stk', 'swb'
];
$uploaded = [];
$skipped = [];
$names = $_FILES['file']['name'] ?? [];
foreach ((array) $names as $index => $original_name) {
$single_file = [
'name' => $_FILES['file']['name'][$index],
'type' => $_FILES['file']['type'][$index],
'tmp_name' => $_FILES['file']['tmp_name'][$index],
'error' => $_FILES['file']['error'][$index],
'size' => $_FILES['file']['size'][$index]
];
$file_reference_name = checkFileUpload($single_file, $allowedExtensions);
if (!$file_reference_name) {
$skipped[] = $original_name;
continue;
}
$dest_path = "$client_dir/$file_reference_name";
if (!move_uploaded_file($single_file['tmp_name'], $dest_path)) {
$skipped[] = $original_name;
continue;
}
$file_name = escapeSql(basename($original_name));
$file_extension = escapeSql(strtolower(pathinfo($original_name, PATHINFO_EXTENSION)));
// What the file is, read from the file - not what the browser said it was
$detected_mime = function_exists('finfo_open') ? (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $dest_path) : '';
$file_mime_type = escapeSql($detected_mime !== '' && $detected_mime !== 'application/octet-stream' ? $detected_mime : $single_file['type']);
$file_size = intval($single_file['size']);
mysqli_query($mysqli, "INSERT INTO files SET
file_reference_name = '$file_reference_name',
file_name = '$file_name',
file_description = '$description',
file_ext = '$file_extension',
file_mime_type = '$file_mime_type',
file_size = $file_size,
file_created_by = $session_user_id,
file_folder_id = $folder_id,
file_client_id = $client_id");
$file_id = mysqli_insert_id($mysqli);
if ($contact_id) {
mysqli_query($mysqli, "INSERT IGNORE INTO contact_files SET contact_id = $contact_id, file_id = $file_id");
}
if ($asset_id) {
mysqli_query($mysqli, "INSERT IGNORE INTO asset_files SET asset_id = $asset_id, file_id = $file_id");
}
logAudit("File", "Upload", "$session_name uploaded file $file_name", $client_id, $file_id);
$uploaded[] = basename($original_name);
}
if ($uploaded && !$skipped) {
flashAlert(count($uploaded) === 1 ? "Uploaded <strong>{$uploaded[0]}</strong>" : "Uploaded <strong>" . count($uploaded) . "</strong> files");
} elseif ($uploaded) {
flashAlert("Uploaded <strong>" . count($uploaded) . "</strong>, skipped <strong>" . count($skipped) . "</strong> (not an allowed type, empty or too big): " . implode(', ', $skipped), 'warning');
} else {
flashAlert($skipped ? "Nothing uploaded - not an allowed type, empty or too big: " . implode(', ', $skipped) : 'Choose a file to upload.', 'error');
}
redirect();
}
if (isset($_POST['rename_file'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$file_id = intval($_POST['file_id']);
$file_name = escapeSql(trim($_POST['file_name'] ?? ''));
$file_description = escapeSql($_POST['file_description'] ?? '');
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT file_name, file_client_id FROM files WHERE file_id = $file_id"));
if (!$row) {
flashAlert('That file no longer exists.', 'error');
redirect();
}
$old_file_name = escapeSql($row['file_name']);
$client_id = intval($row['file_client_id']);
enforceClientAccess();
if ($file_name === '') {
flashAlert('A file needs a name.', 'error');
redirect();
}
mysqli_query($mysqli, "UPDATE files SET file_name = '$file_name', file_description = '$file_description' WHERE file_id = $file_id");
logAudit("File", "Rename", "$session_name renamed file $old_file_name to $file_name", $client_id, $file_id);
flashAlert("File <strong>" . trim($_POST['file_name']) . "</strong> saved");
redirect();
}
if (isset($_POST['move_file'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$file_id = intval($_POST['file_id']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT file_name, file_client_id FROM files WHERE file_id = $file_id"));
if (!$row) {
flashAlert('That file no longer exists.', 'error');
redirect();
}
$file_name = escapeSql($row['file_name']);
$client_id = intval($row['file_client_id']);
enforceClientAccess();
// Only into one of this client's folders
$folder_id = folderOwnedId($_POST['folder_id'] ?? 0, $client_id);
$folder_name = folderDisplayName($folder_id);
mysqli_query($mysqli, "UPDATE files SET file_folder_id = $folder_id WHERE file_id = $file_id");
logAudit("File", "Move", "$session_name moved file $file_name to " . escapeSql($folder_name), $client_id, $file_id);
flashAlert("File <strong>{$row['file_name']}</strong> moved to <strong>$folder_name</strong>");
redirect();
}
if (isset($_GET['archive_file'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$file_id = intval($_GET['archive_file']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT file_name, file_client_id FROM files WHERE file_id = $file_id"));
if (!$row) {
flashAlert('That file no longer exists.', 'error');
redirect();
}
$client_id = intval($row['file_client_id']);
enforceClientAccess();
mysqli_query($mysqli, "UPDATE files SET file_archived_at = NOW() WHERE file_id = $file_id");
logAudit("File", "Archive", "$session_name archived file " . escapeSql($row['file_name']), $client_id, $file_id);
flashAlert("File <strong>{$row['file_name']}</strong> archived", 'error');
redirect();
}
if (isset($_GET['restore_file'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$file_id = intval($_GET['restore_file']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT file_name, file_client_id FROM files WHERE file_id = $file_id"));
if (!$row) {
flashAlert('That file no longer exists.', 'error');
redirect();
}
$client_id = intval($row['file_client_id']);
enforceClientAccess();
mysqli_query($mysqli, "UPDATE files SET file_archived_at = NULL WHERE file_id = $file_id");
logAudit("File", "Restore", "$session_name restored file " . escapeSql($row['file_name']), $client_id, $file_id);
flashAlert("File <strong>{$row['file_name']}</strong> restored");
redirect();
}
if (isset($_POST['delete_file'])) {
validateCSRFToken();
enforceUserPermission('module_support', 3);
$file_name = fileDeleteForGood(intval($_POST['file_id']));
if ($file_name === null) {
flashAlert('That file no longer exists.', 'error');
} else {
flashAlert("File <strong>$file_name</strong> deleted", 'error');
}
redirect();
}
/*
* The bulk actions take files and documents together - the files list ticks
* both - and each item is checked against the agent's client access on its
* own, since nothing ties the ticked ids to one client.
*/
if (isset($_POST['bulk_archive_files'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$file_count = 0;
$document_count = 0;
foreach (array_map('intval', (array) ($_POST['file_ids'] ?? [])) as $file_id) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT file_client_id, file_name FROM files WHERE file_id = $file_id AND file_archived_at IS NULL"));
if (!$row) {
continue;
}
$client_id = intval($row['file_client_id']);
enforceClientAccess();
mysqli_query($mysqli, "UPDATE files SET file_archived_at = NOW() WHERE file_id = $file_id");
logAudit("File", "Archive", "$session_name archived file " . escapeSql($row['file_name']), $client_id, $file_id);
$file_count++;
}
foreach (array_map('intval', (array) ($_POST['document_ids'] ?? [])) as $document_id) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT document_name, document_client_id FROM documents WHERE document_id = $document_id AND document_archived_at IS NULL"));
if (!$row) {
continue;
}
$client_id = intval($row['document_client_id']);
enforceClientAccess();
mysqli_query($mysqli, "UPDATE documents SET document_archived_at = NOW(), document_updated_at = document_updated_at WHERE document_id = $document_id");
logAudit("Document", "Archive", "$session_name archived document " . escapeSql($row['document_name']), $client_id, $document_id);
$document_count++;
}
flashAlert("Archived " . filesCountPhrase($document_count, $file_count), 'error');
redirect();
}
if (isset($_POST['bulk_restore_files'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$file_count = 0;
$document_count = 0;
foreach (array_map('intval', (array) ($_POST['file_ids'] ?? [])) as $file_id) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT file_client_id, file_name FROM files WHERE file_id = $file_id AND file_archived_at IS NOT NULL"));
if (!$row) {
continue;
}
$client_id = intval($row['file_client_id']);
enforceClientAccess();
mysqli_query($mysqli, "UPDATE files SET file_archived_at = NULL WHERE file_id = $file_id");
logAudit("File", "Restore", "$session_name restored file " . escapeSql($row['file_name']), $client_id, $file_id);
$file_count++;
}
foreach (array_map('intval', (array) ($_POST['document_ids'] ?? [])) as $document_id) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT document_name, document_client_id FROM documents WHERE document_id = $document_id AND document_archived_at IS NOT NULL"));
if (!$row) {
continue;
}
$client_id = intval($row['document_client_id']);
enforceClientAccess();
mysqli_query($mysqli, "UPDATE documents SET document_archived_at = NULL, document_updated_at = document_updated_at WHERE document_id = $document_id");
logAudit("Document", "Restore", "$session_name restored document " . escapeSql($row['document_name']), $client_id, $document_id);
$document_count++;
}
flashAlert("Restored " . filesCountPhrase($document_count, $file_count));
redirect();
}
if (isset($_POST['bulk_delete_files'])) {
validateCSRFToken();
enforceUserPermission('module_support', 3);
$file_count = 0;
$document_count = 0;
foreach (array_map('intval', (array) ($_POST['file_ids'] ?? [])) as $file_id) {
if (fileDeleteForGood($file_id) !== null) {
$file_count++;
}
}
foreach (array_map('intval', (array) ($_POST['document_ids'] ?? [])) as $document_id) {
if (documentDeleteForGood($document_id) !== null) {
$document_count++;
}
}
flashAlert("Deleted " . filesCountPhrase($document_count, $file_count), 'error');
redirect();
}
if (isset($_POST['bulk_move_files'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$target_folder = intval($_POST['bulk_folder_id'] ?? 0);
$folder_name = folderDisplayName($target_folder);
$file_count = 0;
$document_count = 0;
// Each item only moves into a folder of its own client - one of another
// client's folders would take it out of sight of both
foreach (array_map('intval', (array) ($_POST['file_ids'] ?? [])) as $file_id) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT file_client_id, file_name FROM files WHERE file_id = $file_id"));
if (!$row) {
continue;
}
$client_id = intval($row['file_client_id']);
enforceClientAccess();
if ($target_folder && folderOwnedId($target_folder, $client_id) !== $target_folder) {
continue;
}
mysqli_query($mysqli, "UPDATE files SET file_folder_id = $target_folder WHERE file_id = $file_id");
logAudit("File", "Move", "$session_name moved file " . escapeSql($row['file_name']) . " to " . escapeSql($folder_name), $client_id, $file_id);
$file_count++;
}
foreach (array_map('intval', (array) ($_POST['document_ids'] ?? [])) as $document_id) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT document_client_id, document_name FROM documents WHERE document_id = $document_id"));
if (!$row) {
continue;
}
$client_id = intval($row['document_client_id']);
enforceClientAccess();
if ($target_folder && folderOwnedId($target_folder, $client_id) !== $target_folder) {
continue;
}
mysqli_query($mysqli, "UPDATE documents SET document_folder_id = $target_folder, document_updated_at = document_updated_at WHERE document_id = $document_id");
logAudit("Document", "Move", "$session_name moved document " . escapeSql($row['document_name']) . " to " . escapeSql($folder_name), $client_id, $document_id);
$document_count++;
}
flashAlert("Moved " . filesCountPhrase($document_count, $file_count) . " to <strong>$folder_name</strong>");
redirect();
}