Files
itflow/functions/files.php
johnnyq ecbb974fff API stage 3, calendar events, trips, accounts and money, contracts, and files and folders: calendars can be read, and calendar events read with their attendees, created once or repeating for a client or nobody, changed, sent as invitations, skipped for a day of a series and deleted, following calendarEventCan(); trips can be read, logged, changed and deleted through tripSave() and tripDelete(), anyone their own and someone else's with Financial; accounts can be read with their balances, added, changed, archived, unarchived and deleted while nothing is on them, other income and transfers read, recorded, changed and deleted, and recurring expenses read, set up, changed, paused and resumed, recorded now, moved on past missed runs and deleted, keeping reconciled lines as they were; contracts can be read with their prepaid hours and signing state, added, changed, made active, renewed, cancelled and deleted, sent for signature or the link sent again, the request withdrawn, prepaid hours added with a draft invoice or deleted, tickets' time drawn or put back, and the billing recurring invoice made, linked or unlinked; a client's files can be read, uploaded as base64, downloaded, renamed, moved, archived, unarchived and deleted once archived, and folders read, made, renamed, moved and deleted with their contents moved up; new api/v1/form_input.php builds a save function's input from the API's field names; the upload allow-list moves from agent/post/file.php into fileUploadExtensions() so Upload Files and the API check the same list; enforce_api_rbac.php maps the new resources, lets events, calendars and trips check their own rules, puts the new state changes at Modify and treats files/download as a read; 26.11.9 changelog entries
2026-11-13 23:51:10 -05:00

694 lines
26 KiB
PHP

<?php
// Filesystem and embedded-image helpers
// Split from the former monolithic functions.php
/** What a file attached to a ticket can be - by its extension. Canned responses' files too. */
const TICKET_ATTACHMENT_EXTENSIONS = [
'jpg', 'jpeg', 'gif', 'png', 'webp', 'pdf', 'txt', 'md', 'doc', 'docx',
'odt', 'csv', 'xls', 'xlsx', 'ods', 'pptx', 'odp', 'zip', 'tar', 'gz',
'xml', 'msg', 'json', 'wav', 'mp3', 'ogg', 'mov', 'mp4', 'av1', 'ovpn'
];
function removeDirectory($path) {
if (!file_exists($path)) {
return;
}
$files = glob($path . '/*');
foreach ($files as $file) {
is_dir($file) ? removeDirectory($file) : unlink($file);
}
rmdir($path);
}
function copyDirectory($src, $dst) {
if (!is_dir($src)) {
return;
}
if (!is_dir($dst)) {
mkdir($dst, 0775, true);
}
$items = scandir($src);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$srcPath = $src . '/' . $item;
$dstPath = $dst . '/' . $item;
if (is_dir($srcPath)) {
copyDirectory($srcPath, $dstPath);
} else {
copy($srcPath, $dstPath);
}
}
}
function mkdirMissing($dir) {
if (!is_dir($dir)) {
mkdir($dir);
}
}
function saveBase64Images(string $html, string $baseFsPath, string $baseWebPath, int $ownerId): string {
// Normalize paths
$baseFsPath = rtrim($baseFsPath, '/\\') . '/';
$baseWebPath = rtrim($baseWebPath, '/\\') . '/';
$targetDir = $baseFsPath . $ownerId . "/";
$folderCreated = false; // <-- NEW FLAG
$savedAny = false; // <-- Track if ANY images processed
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html);
libxml_clear_errors();
$imgs = $dom->getElementsByTagName('img');
foreach ($imgs as $img) {
$src = $img->getAttribute('src');
// Match base64 images
if (preg_match('/^data:image\/([a-zA-Z0-9+]+);base64,(.*)$/s', $src, $matches)) {
$savedAny = true; // <-- We are actually saving at least 1 image
// Create folder ONLY when needed
if (!$folderCreated) {
if (!is_dir($targetDir)) {
mkdir($targetDir, 0775, true);
}
$folderCreated = true;
}
$mimeType = strtolower($matches[1]);
$base64 = $matches[2];
$binary = base64_decode($base64);
if ($binary === false) {
continue;
}
// Extension mapping
switch ($mimeType) {
case 'jpeg':
case 'jpg': $ext = 'jpg'; break;
case 'png': $ext = 'png'; break;
case 'gif': $ext = 'gif'; break;
case 'webp': $ext = 'webp'; break;
default: $ext = 'png';
}
// Secure random filename
$uid = bin2hex(random_bytes(16));
$filename = "img_{$uid}.{$ext}";
$filePath = $targetDir . $filename;
if (file_put_contents($filePath, $binary) !== false) {
$webPath = "/" . $baseWebPath . $ownerId . "/" . $filename;
$img->setAttribute('src', $webPath);
}
}
}
// If no images were processed, return original HTML immediately
if (!$savedAny) {
return $html;
}
// Extract body content only
$body = $dom->getElementsByTagName('body')->item(0);
if ($body) {
$innerHTML = '';
foreach ($body->childNodes as $child) {
$innerHTML .= $dom->saveHTML($child);
}
return $innerHTML;
}
return $html;
}
function cleanupUnusedImages(string $html, string $folderFsPath, string $folderWebPath) {
$folderFsPath = rtrim($folderFsPath, '/\\') . '/';
$folderWebPath = rtrim($folderWebPath, '/\\') . '/';
if (!is_dir($folderFsPath)) {
return; // no folder = nothing to delete
}
// 1. Get all files currently on disk
$filesOnDisk = glob($folderFsPath . "*");
// 2. Find all <img src="">
preg_match_all('/<img[^>]+src=["\']([^"\']+)["\']/i', $html, $matches);
$htmlImagePaths = $matches[1] ?? [];
// Normalize paths: keep only filenames belonging to this template folder
$referencedFiles = [];
foreach ($htmlImagePaths as $src) {
if (strpos($src, $folderWebPath) !== false) {
$filename = basename($src);
$referencedFiles[] = $filename;
}
}
// 3. Delete any physical file not referenced in the HTML
foreach ($filesOnDisk as $filePath) {
$filename = basename($filePath);
if (!in_array($filename, $referencedFiles)) {
unlink($filePath);
}
}
}
/*
* Ticket attachment uploads
*
* Shared by the agent ticket page, the new ticket modal and the client portal
* reply form, so the allowed extension list has one definition rather than one
* per caller.
*
* Files land in uploads/tickets/<ticket id>/ under an unguessable reference name
* and are only ever served back through the ticket_attachment.php endpoints,
* which re-check permissions and force a safe Content-Type.
*
* Pass $reply_id to attach to a specific reply, or null to attach to the ticket
* itself - the ticket page reads reply_id IS NULL as "belongs to the ticket".
*
* Returns a list of what was stored - ['name' => original name, 'path' => path
* relative to the app root, 'size' => bytes] - so a caller can pass the same
* files to the mail queue. Anything the extension allow-list or checkFileUpload()
* rejects is skipped silently, as it always has been.
*/
function saveTicketAttachments($ticket_id, $reply_id = null, $field_name = 'attachments') {
global $mysqli;
$ticket_id = intval($ticket_id);
$stored_attachments = [];
if (!$ticket_id || empty($_FILES[$field_name]) || !isset($_FILES[$field_name]['name'])) {
return $stored_attachments;
}
$allowed_extensions = TICKET_ATTACHMENT_EXTENSIONS;
// A single-file input posts scalars, a multiple one posts arrays - normalize
$names = $_FILES[$field_name]['name'];
if (!is_array($names)) {
return $stored_attachments;
}
mkdirMissing('../uploads/tickets/');
$upload_file_dir = "../uploads/tickets/" . $ticket_id . "/";
mkdirMissing($upload_file_dir);
if ($reply_id === null) {
$reply_id_sql = 'NULL';
} else {
$reply_id_sql = intval($reply_id);
}
for ($i = 0; $i < count($names); $i++) {
$single_file = [
'name' => $_FILES[$field_name]['name'][$i],
'type' => $_FILES[$field_name]['type'][$i],
'tmp_name' => $_FILES[$field_name]['tmp_name'][$i],
'error' => $_FILES[$field_name]['error'][$i],
'size' => $_FILES[$field_name]['size'][$i]
];
$attachment_reference_name = checkFileUpload($single_file, $allowed_extensions);
if (!$attachment_reference_name) {
continue;
}
$destination_path = $upload_file_dir . $attachment_reference_name;
if (!move_uploaded_file($single_file['tmp_name'], $destination_path)) {
continue;
}
$attachment_name = escapeSql($single_file['name']);
mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$attachment_name', ticket_attachment_reference_name = '$attachment_reference_name', ticket_attachment_reply_id = $reply_id_sql, ticket_attachment_ticket_id = $ticket_id");
// Path is relative to the app root, not the caller, so the mail cron can
// resolve it from its own directory
$stored_attachments[] = [
'name' => $single_file['name'],
'path' => "uploads/tickets/$ticket_id/$attachment_reference_name",
'size' => (int) $single_file['size']
];
}
return $stored_attachments;
}
/*
* Splits a stored attachment list into what the mail queue will carry and what it
* will not, against MAX_EMAIL_ATTACHMENT_BYTES applied to the message as a whole.
*
* Oversized files stay on the ticket - the recipient can still download them from
* the portal - and the caller is expected to say so rather than leaving the agent
* to assume everything went out.
*
* Returns ['send' => [...], 'skipped' => [...]] in the input order.
*/
function filterEmailableAttachments($attachments) {
$result = ['send' => [], 'skipped' => []];
$running_total = 0;
foreach ($attachments as $attachment) {
$size = intval($attachment['size'] ?? 0);
if ($size > 0 && $running_total + $size <= MAX_EMAIL_ATTACHMENT_BYTES) {
$running_total += $size;
$result['send'][] = $attachment;
} else {
$result['skipped'][] = $attachment;
}
}
return $result;
}
/*
* FontAwesome icon name for a file extension.
*
* Lived in client/functions.php until agent/files.php needed the same mapping
* for its gallery view. The portal reaches this file through the root
* functions.php it already loads, so moving it up costs the portal nothing and
* leaves one list of extensions rather than two to drift apart.
*/
function getFileIcon($file_extension) {
$file_extension = strtolower($file_extension);
// Document icons
if (in_array($file_extension, ['pdf'])) {
return 'file-pdf';
} elseif (in_array($file_extension, ['doc', 'docx'])) {
return 'file-word';
} elseif (in_array($file_extension, ['xls', 'xlsx'])) {
return 'file-excel';
} elseif (in_array($file_extension, ['ppt', 'pptx'])) {
return 'file-powerpoint';
} elseif (in_array($file_extension, ['txt', 'md', 'rtf'])) {
return 'file-alt';
} elseif (in_array($file_extension, ['zip', 'rar', '7z', 'tar', 'gz'])) {
return 'file-archive';
} elseif (in_array($file_extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'])) {
return 'file-image';
} elseif (in_array($file_extension, ['mp4', 'avi', 'mov', 'wmv', 'flv'])) {
return 'file-video';
} elseif (in_array($file_extension, ['mp3', 'wav', 'ogg', 'flac'])) {
return 'file-audio';
} elseif (in_array($file_extension, ['html', 'htm', 'css', 'js', 'php', 'py', 'java'])) {
return 'file-code';
} else {
return 'file';
}
}
/*
* MIME types safe to render inline in a browser.
*
* Anything not on this list is served Content-Disposition: attachment - HTML
* and SVG in particular are stored-XSS vectors when a browser renders them from
* our own origin. Four file-serving entry points enforced it from four
* identical copies of the array; the gallery preview in agent/files.php has to
* agree with them about what it can frame, so the list is defined once here.
*
* application/json is on the list because a browser renders it as text, not as
* markup, and every serving point sends X-Content-Type-Options: nosniff so it
* cannot be re-interpreted as HTML.
*/
function getInlineViewableMimeTypes() {
return [
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"text/plain",
"application/json"
];
}
/*
* Tidies raw text into something worth showing in a preview tile.
*
* Two callers, two different messes, one clean-up:
*
* documents - document_content_raw is TinyMCE HTML that has been through
* strip_tags(), which removes the TAGS and leaves the ENTITIES.
* A paragraph of "Hello&nbsp;world &amp; friends" arrives with
* those sequences intact, and escaping it for output turned them
* into a literal &amp;nbsp; on screen. Decode first, escape last.
*
* text files - whatever bytes are on disk: a UTF-8 BOM that renders as a
* stray glyph, CRLF line endings, stray control characters, and
* the possibility that a file claiming text/plain is not text.
*
* Returns '' when there is nothing worth showing, so callers fall back to the
* file-type icon rather than printing noise.
*/
function cleanTextExcerpt($text, $length = 400) {
$text = (string) $text;
// A BOM is invisible metadata to a text editor and a stray glyph in HTML
$text = preg_replace('/^\xEF\xBB\xBF/', '', $text);
// Entities left behind by strip_tags(), plus TinyMCE's non-breaking spaces,
// which are U+00A0 once decoded and read as odd gaps
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = str_replace("\xC2\xA0", ' ', $text);
// Drop anything that is not valid UTF-8 rather than letting a half-decoded
// byte render as a replacement character
if (!mb_check_encoding($text, 'UTF-8')) {
$text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
}
// Control characters, keeping the two that carry meaning in a preview.
// No /u modifier on purpose: these are all single-byte ASCII, and every
// continuation byte of a multi-byte character is >= 0x80, so a byte-wise
// strip cannot damage one. With /u the whole call returns null the moment
// the subject holds a stray invalid byte, blanking the excerpt instead of
// cleaning it.
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
$text = str_replace(["\r\n", "\r"], "\n", $text);
// Stripped HTML leaves long runs of blank lines and indentation
$text = preg_replace('/[ \t]+/', ' ', $text);
$text = preg_replace('/\n{2,}/', "\n", $text);
$text = trim($text);
// Cut on a character boundary - a byte-wise substr can split a multi-byte
// character and leave a broken glyph at the end of every tile
if (mb_strlen($text, 'UTF-8') > $length) {
$text = mb_substr($text, 0, $length, 'UTF-8') . '...';
}
return (string) $text;
}
/*
* First few hundred characters of a text file, for a grid tile preview.
*
* Reads a bounded chunk rather than the whole file - a 40MB log has no business
* being loaded into a page that shows two dozen tiles - and re-applies the same
* realpath containment check the file-serving endpoints use, because the caller
* is building a path from a database column.
*
* Returns '' when the file is missing, unreadable, escapes uploads/, or does
* not look like text at all. The last case matters: a mislabelled binary served
* as text/plain would otherwise fill the tile with garbage.
*/
function getFileTextExcerpt($client_id, $file_reference_name, $length = 400) {
$client_id = intval($client_id);
$uploads_base = realpath(__DIR__ . "/../uploads");
$file_path = realpath(__DIR__ . "/../uploads/clients/$client_id/$file_reference_name");
if ($file_path === false || $uploads_base === false || strpos($file_path, $uploads_base) !== 0) {
return '';
}
if (!is_file($file_path) || !is_readable($file_path)) {
return '';
}
$handle = fopen($file_path, 'rb');
if ($handle === false) {
return '';
}
// Read well past the target so cleaning and the character-boundary cut
// still have a full excerpt to work with
$raw = fread($handle, $length * 4);
fclose($handle);
if ($raw === false || $raw === '') {
return '';
}
// Does this actually look like text? Count the bytes no text file should
// carry; a few percent is a mislabelled binary, not a stray character.
$control_bytes = strlen(preg_replace('/[^\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $raw));
if ($control_bytes > 0 && ($control_bytes / strlen($raw)) > 0.05) {
return '';
}
return cleanTextExcerpt($raw, $length);
}
/*
* Files & documents section - how a file's type is shown, and the client's
* folder tree. Shared by agent/files.php, the document page, the file and
* document quick looks and the upload, move and link modals.
*/
/**
* What kind of file an extension is: its icon, a short label, and the colour
* family its tile is drawn in (.itflow-file-tone-* in itflow_custom.css).
* Leans on getFileIcon() for the icon where the two agree, so the portal and
* the agent side still read from one list of extensions.
*/
function fileTypeInfo($extension) {
$extension = strtolower(trim((string) $extension));
$kinds = [
['pdf', ['pdf'], 'file-pdf', 'PDF'],
['word', ['doc', 'docx', 'odt', 'rtf', 'pages'], 'file-word', 'Word'],
['excel', ['xls', 'xlsx', 'ods', 'csv', 'numbers'], 'file-excel', 'Spreadsheet'],
['slides', ['ppt', 'pptx', 'odp'], 'file-powerpoint', 'Presentation'],
['image', ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tif', 'tiff', 'heic', 'svg'], 'file-image', 'Image'],
['media', ['mp4', 'mov', 'av1', 'avi', 'mkv', 'webm', 'wmv'], 'file-video', 'Video'],
['media', ['mp3', 'wav', 'ogg', 'flac', 'm4a'], 'file-audio', 'Audio'],
['archive', ['zip', 'tar', 'gz', 'tgz', '7z', 'rar'], 'file-archive', 'Archive'],
['text', ['txt', 'md', 'log'], 'file-alt', 'Text'],
['text', ['msg', 'eml'], 'envelope', 'Email'],
['code', ['json', 'cfg', 'conf', 'ini', 'xml', 'yaml', 'yml', 'ovpn', 'ps1', 'bat', 'sh', 'unf', 'unifi', 'stk', 'swb'], 'file-code', 'Config / script'],
['drawing', ['vsdx', 'drawio'], 'project-diagram', 'Diagram'],
['cert', ['pfx', 'p12', 'cer', 'crt', 'pem', 'key'], 'certificate', 'Certificate / key'],
];
foreach ($kinds as [$tone, $extensions, $icon, $label]) {
if (in_array($extension, $extensions, true)) {
return ['tone' => $tone, 'icon' => $icon, 'label' => $label];
}
}
return ['tone' => 'other', 'icon' => getFileIcon($extension), 'label' => $extension !== '' ? strtoupper($extension) . ' file' : 'File'];
}
/** A file's type as a coloured tile - the files list, linked items, the quick look. $size is '', 'sm' or 'lg'. */
function fileTypeTile($extension, $size = '') {
$info = fileTypeInfo($extension);
return '<span class="itflow-file-icon itflow-file-tone-' . $info['tone'] . ($size ? " itflow-file-icon-$size" : '') . '" title="' . escapeHtml($info['label']) . '" aria-hidden="true"><i class="fas fa-fw fa-' . $info['icon'] . '"></i></span>';
}
/** A created document's tile, in the same family as fileTypeTile(). */
function documentTile($size = '') {
return '<span class="itflow-file-icon itflow-file-tone-document' . ($size ? " itflow-file-icon-$size" : '') . '" title="Document" aria-hidden="true"><i class="fas fa-fw fa-file-alt"></i></span>';
}
/**
* Every folder a client has, in one query: id => [folder_id, folder_name,
* parent_folder, children => [ids]], with 0 standing in for the root.
* Folders whose parent no longer exists hang off the root, so nothing in
* them is ever out of reach.
*/
function folderTree($client_id) {
global $mysqli;
$client_id = intval($client_id);
$tree = [0 => ['folder_id' => 0, 'folder_name' => '/', 'parent_folder' => -1, 'children' => []]];
$sql = mysqli_query($mysqli, "SELECT folder_id, folder_name, parent_folder FROM folders WHERE folder_client_id = $client_id ORDER BY folder_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$tree[intval($row['folder_id'])] = ['folder_id' => intval($row['folder_id']), 'folder_name' => $row['folder_name'], 'parent_folder' => intval($row['parent_folder']), 'children' => []];
}
foreach ($tree as $folder_id => $folder) {
if ($folder_id === 0) {
continue;
}
$parent = isset($tree[$folder['parent_folder']]) && $folder['parent_folder'] !== $folder_id ? $folder['parent_folder'] : 0;
$tree[$folder_id]['parent_folder'] = $parent;
$tree[$parent]['children'][] = $folder_id;
}
return $tree;
}
/** The folders from the root down to $folder_id, root left out: [[folder_id, folder_name], ...]. */
function folderPath($tree, $folder_id) {
$path = [];
$seen = [];
$folder_id = intval($folder_id);
while ($folder_id && isset($tree[$folder_id]) && !isset($seen[$folder_id])) {
$seen[$folder_id] = true;
array_unshift($path, ['folder_id' => $folder_id, 'folder_name' => $tree[$folder_id]['folder_name']]);
$folder_id = $tree[$folder_id]['parent_folder'];
}
return $path;
}
/** $folder_id and every folder under it. */
function folderSubtree($tree, $folder_id) {
$ids = [];
$stack = [intval($folder_id)];
while ($stack) {
$id = array_pop($stack);
if (!isset($tree[$id]) || isset($ids[$id])) {
continue;
}
$ids[$id] = true;
foreach ($tree[$id]['children'] as $child) {
$stack[] = $child;
}
}
return array_keys($ids);
}
/**
* <option>s for a folder picker: the root, then each folder indented under
* its parent, with $selected chosen. $exclude leaves out a folder and
* everything under it - a folder cannot be moved into itself.
*/
function folderOptionsHtml($client_id, $selected = 0, $exclude = 0) {
$tree = folderTree($client_id);
$skip = $exclude ? array_flip(folderSubtree($tree, $exclude)) : [];
$selected = intval($selected);
$html = '<option value="0"' . ($selected === 0 ? ' selected' : '') . selectOptionData(['icon' => 'fas fa-home']) . '>Top level</option>';
$walk = function ($parent_id, $depth) use (&$walk, $tree, $skip, $selected, &$html) {
foreach ($tree[$parent_id]['children'] as $folder_id) {
if (isset($skip[$folder_id])) {
continue;
}
$path = implode(' / ', array_column(folderPath($tree, $tree[$folder_id]['parent_folder']), 'folder_name'));
$html .= '<option value="' . $folder_id . '"' . ($folder_id === $selected ? ' selected' : '')
. selectOptionData(['icon' => 'fas fa-folder', 'description' => $path])
. '>' . str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . escapeHtml($tree[$folder_id]['folder_name']) . '</option>';
$walk($folder_id, $depth + 1);
}
};
$walk(0, 0);
return $html;
}
/** The kinds of file a client's Files take - by extension. Upload Files and the API's files/create both check against it. */
function fileUploadExtensions() {
return [
'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'
];
}
/** $folder_id if it is one of the client's folders (0, the top level, always is), otherwise 0. */
function folderOwnedId($folder_id, $client_id) {
global $mysqli;
$folder_id = intval($folder_id);
if (!$folder_id) {
return 0;
}
$sql = mysqli_query($mysqli, "SELECT 1 FROM folders WHERE folder_id = $folder_id AND folder_client_id = " . intval($client_id) . " LIMIT 1");
return mysqli_num_rows($sql) ? $folder_id : 0;
}
/** A folder's name for messages and logs - "Top level" for the root. Raw. */
function folderDisplayName($folder_id) {
$folder_id = intval($folder_id);
return $folder_id ? (string) getFieldById('folders', $folder_id, 'folder_name') : 'Top level';
}
/*
* Deleting for good - shared by the single and bulk delete handlers in
* agent/post/file.php and agent/post/document.php. Each checks the agent's
* access to the record's client itself, and takes every link with it.
*/
/** Remove a stored file from disk - quietly, when it is already gone. */
function fileRemoveFromDisk($client_id, $file_reference_name) {
$path = __DIR__ . "/../uploads/clients/" . intval($client_id) . "/" . basename((string) $file_reference_name);
if ((string) $file_reference_name !== '' && is_file($path)) {
unlink($path);
}
}
/** Delete one file for good: the file on disk, its links and its row. Returns its name, or null if it is not there. */
function fileDeleteForGood($file_id) {
global $mysqli, $session_name, $client_id;
$file_id = intval($file_id);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT file_client_id, file_name, file_reference_name FROM files WHERE file_id = $file_id"));
if (!$row) {
return null;
}
$client_id = intval($row['file_client_id']);
enforceClientAccess();
fileRemoveFromDisk($client_id, $row['file_reference_name']);
linkRemoveAll('file', $file_id);
mysqli_query($mysqli, "DELETE FROM files WHERE file_id = $file_id");
logAudit("File", "Delete", "$session_name deleted file " . escapeSql($row['file_name']), $client_id);
return $row['file_name'];
}
/** Delete one document for good, with its versions, images and links. Returns its name, or null. */
function documentDeleteForGood($document_id) {
global $mysqli, $session_name, $client_id;
$document_id = intval($document_id);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT document_client_id, document_name FROM documents WHERE document_id = $document_id"));
if (!$row) {
return null;
}
$client_id = intval($row['document_client_id']);
enforceClientAccess();
mysqli_query($mysqli, "DELETE FROM documents WHERE document_id = $document_id");
mysqli_query($mysqli, "DELETE FROM document_versions WHERE document_version_document_id = $document_id");
linkRemoveAll('document', $document_id);
removeDirectory($_SERVER['DOCUMENT_ROOT'] . "/uploads/documents/" . $document_id);
logAudit("Document", "Delete", "$session_name deleted document " . escapeSql($row['document_name']) . " and all versions", $client_id);
return $row['document_name'];
}
/**
* What a document's full-text search runs over (document_content_raw): its
* name and its text, tags stripped. A space goes in front of every tag first,
* so "<li>one</li><li>two</li>" reads "one two", not "onetwo". Raw - escape
* it for the query.
*/
function documentContentRaw($name, $html) {
return trim(trim((string) $name) . ' ' . trim(preg_replace('/\s+/u', ' ', strip_tags(str_replace('<', ' <', (string) $html)))));
}
/**
* A document's HTML made safe to show - the HTMLPurifier setup the document
* page, the document quick look and the version viewer all use.
*/
function documentHtmlPurify($html) {
static $purifier = null;
if ($purifier === null) {
require_once __DIR__ . '/../libs/htmlpurifier/HTMLPurifier.standalone.php';
$config = HTMLPurifier_Config::createDefault();
$config->set('Cache.DefinitionImpl', null);
$config->set('URI.AllowedSchemes', ['data' => true, 'src' => true, 'http' => true, 'https' => true]);
$purifier = new HTMLPurifier($config);
}
return $purifier->purify((string) $html);
}