first commit

This commit is contained in:
2025-07-18 16:20:14 +07:00
commit 98af45c018
16382 changed files with 3148096 additions and 0 deletions

763
include/file_functions.php Normal file
View File

@@ -0,0 +1,763 @@
<?php
/**
* Ensures the filename cannot leave the directory set.
* Only to be used for internal ResourceSpace paths as only a limited character set is supported
*
* @param string $name
* @return string
*/
function safe_file_name($name)
{
// Returns a file name stripped of all non alphanumeric values
// Spaces are replaced with underscores
$alphanum = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
$name = str_replace(' ', '_', $name);
$newname = '';
for ($n = 0; $n < strlen($name); $n++) {
$c = substr($name, $n, 1);
if (strpos($alphanum, $c) !== false) {
$newname .= $c;
}
}
// Set to 250 to allow for total length to be below 255 limit including filename and extension
$newname = mb_substr($newname, 0, 250);
return $newname;
}
/**
* Generate a UID for filnames that can be different from user to user (e.g. contact sheets)
*
* @param integer $user_id
*
* @return string
*/
function generateUserFilenameUID($user_id)
{
if (!is_numeric($user_id) || 0 >= $user_id) {
trigger_error('Bad parameter for generateUserFilenameUID()!');
}
global $rs_session, $scramble_key;
$filename_uid = '';
if (isset($rs_session)) {
$filename_uid .= $rs_session;
}
$filename_uid .= $user_id;
return substr(hash('sha256', $filename_uid . $scramble_key), 0, 15);
}
/**
* Checks if a path is part of a whitelisted list of paths. This applies to both folders and files.
*
* Note: the function is not supposed to check/ validate the syntax of the path (ie. UNIX/ Windows)
*
* @param string $path Path which is going to be checked against whitelisted paths
* @param array $whitelisted_paths List of whitelisted paths
*
* @return boolean
*/
function isPathWhitelisted($path, array $whitelisted_paths)
{
foreach ($whitelisted_paths as $whitelisted_path) {
if (substr_compare($whitelisted_path, $path, 0, strlen($path)) === 0) {
return true;
}
}
return false;
}
/**
* Return a checksum for the given file path.
*
* @param string $path Path to file
* @param bool $forcefull Force use of whole file and ignore $file_checksums_50k setting
*
* @return string|false Return the checksum value, false otherwise.
*/
function get_checksum($path, $forcefull = false)
{
debug("get_checksum( \$path = {$path} );");
global $file_checksums_50k;
if (!is_readable($path)) {
return false;
}
# Generate the ID
if ($file_checksums_50k && !$forcefull) {
# Fetch the string used to generate the unique ID
$use = filesize_unlimited($path) . "_" . file_get_contents($path, false, null, 0, 50000);
$checksum = md5($use);
} else {
$checksum = md5_file($path);
}
return $checksum;
}
/**
* Download remote file to the temp filestore location.
*
* @param string $url Source URL
* @param string $key Optional key to use - to prevent conflicts when simultaneous calls use same file name
*
* @return string|bool Returns the new temp filestore location or false otherwise.
*/
function temp_local_download_remote_file(string $url, string $key = "")
{
$userref = $GLOBALS['userref'] ?? 0;
if ($userref === 0) {
return false;
}
if ($key != "" && preg_match('/\W+/', $key) !== 0) {
// Block potential path traversal - allow only word characters.
return false;
}
$url = trim($url);
$url_original = $url;
// Remove query string from URL
$url = explode('?', $url);
$url = reset($url);
$path_parts = pathinfo(basename($url));
$filename = safe_file_name($path_parts['filename'] ?? '');
$extension = $path_parts['extension'] ?? '';
$filename .= ($extension !== '' ? ".{$extension}" : '');
// When the filename isn't valid, try and get from the HTTP header
$check_in_header = strpos($filename, ".") === false && filter_var($url_original, FILTER_VALIDATE_URL);
foreach ($GLOBALS['valid_upload_remote_sources'] as $valid_upload_remote_src) {
// Support dynamic remote URL that may otherwise be mistaken with a file (e.g. pages/download.php)
if (url_starts_with($valid_upload_remote_src, $url_original)) {
$check_in_header = true;
break;
}
}
if ($check_in_header) {
$urlinfo = parse_url($url);
if (!isset($urlinfo["scheme"]) || !in_array($urlinfo["scheme"], ["http","https"])) {
return false;
}
$headers = get_headers($url_original, true);
foreach ($headers as $header => $headervalue) {
if (
strtolower($header) == "content-disposition"
// Check for double quotes first (e.g. attachment; filename="O'Malley's Bar.pdf")
// OR Check for single quotes (e.g. attachment; filename='Space Travel.jpg')
// OR Get file name up to first space
&&
(
preg_match('/.*filename=[\"]([^\"]+)/', $headervalue, $matches)
|| preg_match('/.*filename=[\']([^\']+)/', $headervalue, $matches)
|| preg_match("/.*filename=([^ ]+)/", $headervalue, $matches)
)
) {
$filename = $matches[1];
}
}
$extension = pathinfo(basename($filename), PATHINFO_EXTENSION);
$filename = safe_file_name(pathinfo(basename($filename), PATHINFO_FILENAME)) . ".{$extension}";
}
if (is_banned_extension($extension)) {
debug('[temp_local_download_remote_file] WARN: Banned extension for ' . $filename);
return false;
}
// Get temp location
$tmp_uniq_path_id = $userref . "_" . $key . generateUserFilenameUID($userref);
$tmp_dir = get_temp_dir(false) . "/remote_files/" . $tmp_uniq_path_id;
if (!is_dir($tmp_dir)) {
mkdir($tmp_dir, 0777, true);
}
$tmp_file_path = $tmp_dir . "/" . $filename;
if ($tmp_file_path == $url) {
// Already downloaded earlier by API call
return $tmp_file_path;
}
// Download the file
$GLOBALS['use_error_exception'] = true;
try {
if (copy($url_original, $tmp_file_path)) {
return $tmp_file_path;
}
} catch (Throwable $t) {
debug(sprintf(
'Failed to download remote file from "%s" to temp location "%s". Reason: %s',
$url_original,
$tmp_file_path,
$t->getMessage()
));
}
unset($GLOBALS['use_error_exception']);
return false;
}
/**
* Basic check of uploaded file against list of allowed extensions
*
* @param array{name: string} $uploadedfile An element from the $_FILES PHP reserved variable
* @param array $validextensions Array of valid extension strings
*/
function check_valid_file_extension(array $uploadedfile, array $validextensions): bool
{
$extension = parse_filename_extension($uploadedfile['name']);
return in_array(strtolower($extension), array_map("strtolower", $validextensions)) && !is_banned_extension($extension);
}
/**
* Is the given extension in the list of blocked extensions?
* Also ensures extension is no longer than 10 characters due to resource.file_extension database column limit
*
* @param string $extension - file extension to check
*/
function is_banned_extension(string $extension): bool
{
return !(
preg_match('/^[a-zA-Z0-9_-]{1,10}$/', $extension) === 1
&& !in_array(mb_strtolower($extension), array_map('mb_strtolower', $GLOBALS['banned_extensions']))
);
}
/**
* Remove empty folder from path to file. Helpful to remove a temp directory once the file it was created to hold no longer exists.
* This function should be called only once the directory to be removed is empty.
*
* @param string $path_to_file Full path to file in filestore.
*
* @return void
*/
function remove_empty_temp_directory(string $path_to_file = "")
{
if ($path_to_file != "" && !file_exists($path_to_file)) {
$tmp_path_parts = pathinfo($path_to_file);
$path_to_folder = str_replace(DIRECTORY_SEPARATOR . $tmp_path_parts['basename'], '', $path_to_file);
rmdir($path_to_folder);
}
}
/**
* Confirm upload path is one of valid paths.
*
* @param string $file_path Upload path.
* @param array $valid_upload_paths Array of valid upload paths to test against.
*
* @return bool true when path is valid else false
*/
function is_valid_upload_path(string $file_path, array $valid_upload_paths): bool
{
$orig_use_error_exception_val = $GLOBALS['use_error_exception'] ?? false;
$GLOBALS["use_error_exception"] = true;
try {
$file_path = realpath($file_path);
} catch (Exception $e) {
debug("Invalid file path specified" . $e->getMessage());
return false;
}
$GLOBALS['use_error_exception'] = $orig_use_error_exception_val;
foreach ($valid_upload_paths as $valid_upload_path) {
if (is_dir($valid_upload_path)) {
$checkpath = realpath($valid_upload_path);
if (strpos($file_path, $checkpath) === 0) {
return true;
}
}
}
return false;
}
/**
* Validate the files on disk that are associated with the given resources
*
* @param array $resources Array of resource IDs or
* array of resource data e.g, from search results
* @param array $criteria Array with an array of callables for each resource with the
* required return values in order to pass the check e.g.
* 'file_exists" =>true for a file presence only check
*
* @return array $results An array with resource ID as the index and the results of the check as the value (boolean)
* e.g. ["1234" => true, "1235" => false]
*/
function validate_resource_files(array $resources, array $criteria = []): array
{
$checkresources = isset($resources[0]["ref"]) ? $resources : get_resource_data_batch($resources);
$results = [];
foreach ($checkresources as $resource) {
if (!is_int_loose($resource["ref"])) {
$results[$resource["ref"]] = false;
continue;
}
$filepath = get_resource_path($resource["ref"], true, '', false, $resource["file_extension"] ?? "jpg");
$results[$resource["ref"]] = false;
foreach ($criteria as $criterion => $expected) {
if (!is_callable($criterion)) {
$results[$resource["ref"]] = false;
// Not a valid check
continue 2;
}
$cscheck = $expected === "%RESOURCE%file_checksum";
if (substr($expected, 0, 10) == "%RESOURCE%") {
// $expected is a resource table column
$expected = $resource[substr($expected, 10)];
}
$testresult = call_user_func($criterion, $filepath);
if ($cscheck && ($expected === null || $expected === "")) {
// No checksum is currently recorded. Update it now that it's been calculated
$results[$resource["ref"]] = true;
debug("Skipping checksum check for resource " . $resource["ref"] . " - no existing checksum");
ps_query("UPDATE resource SET file_checksum = ? WHERE ref = ?", ['s', $testresult, 'i', $resource["ref"]]);
continue;
}
$results[$resource["ref"]] = $testresult === $expected;
if ($results[$resource["ref"]] === false) {
debug($resource["ref"] . " failed integrity check. Expected: " . $criterion . "=" . $expected . ", got : " . $testresult);
// No need to continue with other $criteria as check has failed
continue 2;
}
}
}
return $results;
}
/**
* Check if a given file path is from a valid RS accessible location
*
* @param string $path
* @param array $override_paths Override checking of the default RS paths to check a specific location only.
*/
function is_valid_rs_path(string $path, array $override_paths = []): bool
{
debug_function_call(__FUNCTION__, func_get_args());
if ($GLOBALS["config_windows"]) {
$path = str_replace("\\", "/", $path);
}
$sourcerealpath = realpath($path);
$source_path_not_real = !$sourcerealpath || !file_exists($sourcerealpath);
debug('source_path_not_real = ' . json_encode($source_path_not_real));
$checkname = $path;
$pathinfo = pathinfo($path);
if (($pathinfo["extension"] ?? "") === "icc") {
// ResourceSpace generated .icc files have a double extension, need to strip extension again before checking
$checkname = $pathinfo["filename"] ?? "";
}
debug("checkname = {$checkname}");
if (
$source_path_not_real
&& !(preg_match('/^(?!\.)(?!.*\.$)(?!.*\.\.)[a-zA-Z0-9_\-[:space:]\/:.]+$/', ($pathinfo['dirname'] ?? ""))
&& is_safe_basename($checkname))
) {
debug('Invalid non-existing path');
return false;
}
// Check if path contains symlinks, if so don't use the value returned by realpath() as it is unlikely to match the expected paths
$symlink = false;
$path_parts = array_filter(explode("/", $path));
if ($GLOBALS["config_windows"]) {
$checkpath = "";
} else {
$checkpath = "/";
}
foreach ($path_parts as $path_part) {
$checkpath .= $path_part;
if (!file_exists($checkpath)) {
break;
}
if (check_symlink($checkpath)) {
debug("{$checkpath} is a symlink");
$symlink = true;
break;
}
$checkpath .= "/";
}
$path_to_validate = ($source_path_not_real || $symlink) ? $path : $sourcerealpath;
debug("path_to_validate = {$path_to_validate}");
if (count($override_paths) > 0) {
$default_paths = $override_paths;
} else {
$default_paths = [
dirname(__DIR__) . '/gfx',
$GLOBALS['storagedir'],
$GLOBALS['syncdir'],
$GLOBALS['fstemplate_alt_storagedir'],
];
if (isset($GLOBALS['tempdir'])) {
$default_paths[] = $GLOBALS['tempdir'];
}
}
$allowed_paths = array_filter(array_map('trim', array_unique($default_paths)));
debug('allowed_paths = ' . implode(', ', $allowed_paths));
foreach ($allowed_paths as $allowed_path) {
debug("Iter allowed path - {$allowed_path}");
$validpath = ($source_path_not_real || $symlink) ? $allowed_path : realpath($allowed_path);
if ($GLOBALS["config_windows"]) {
$allowed_path = str_replace("\\", "/", $allowed_path);
$validpath = str_replace("\\", "/", $validpath);
$path_to_validate = str_replace("\\", "/", $path_to_validate);
}
debug("validpath = {$validpath}");
debug("path_to_validate = {$path_to_validate}");
if ($validpath !== false && mb_strpos($path_to_validate, $validpath) === 0) {
debug('Path allowed');
return true;
}
}
debug('Default as an invalid path');
return false;
}
/**
* Validation helper function to determine if a path base name is unsafe (e.g OS command injection).
* Very strict, limited to specific characters only. Should only be used for filenames originating in ResourceSpace.
*/
function is_safe_basename(string $val): bool
{
$file_name = pathinfo($val, PATHINFO_FILENAME);
return
safe_file_name($file_name) === str_replace(' ', '_', $file_name)
&& !is_banned_extension(parse_filename_extension($val));
}
// phpcs:disable
enum ProcessFileUploadErrorCondition
{
case MissingSourceFile;
case EmptySourceFile;
case InvalidUploadPath;
case SpecialFile;
case InvalidExtension;
case MimeTypeMismatch;
case FileMoveFailure;
/**
* Translate error condition to users' language.
* @param array $lang Language string mapping (i.e. the global $lang var)
* @return string The translated version or the conditions' name if not found in the map
*/
public function i18n(array $lang): string
{
return $lang["error_file_upload_cond-{$this->name}"] ?? $this->name;
}
}
// phpcs:enable
/**
* High level function which can handle processing file uploads.
*
* @param SplFileInfo|array{name: string, full_path: string, type: string, tmp_name: string, error: int, size: int} $source
* @param array{
* allow_extensions?: list<string>,
* file_move?: 'move_uploaded_file'|'rename'|'copy',
* mime_file_based_detection?: bool,
* } $processor Processors which can override different parts of the main logic (e.g. allow specific extensions)
*
* @return array{success: bool, error?: ProcessFileUploadErrorCondition}
*/
function process_file_upload(SplFileInfo|array $source, SplFileInfo $destination, array $processor): array
{
if ($source instanceof SplFileInfo) {
$source_file_name = $source->getFilename();
$source_file_path = $source->getRealPath();
$source_is_file = $source->isFile();
$source_file_size = $source_is_file ? $source->getSize() : 0;
$file_move_processor = $processor['file_move'] ?? 'rename';
} else {
$source_file_name = $source['name'];
$source_file_path = $source['tmp_name'];
$source_is_file = file_exists($source_file_path);
$source_file_size = $source_is_file ? filesize($source_file_path) : 0;
$file_move_processor = 'move_uploaded_file';
if (!is_uploaded_file($source['tmp_name'])) {
trigger_error('Invalid $source input. For files not uploaded via HTTP POST, please use SplFileInfo');
exit();
}
}
$fail_due_to = static fn(ProcessFileUploadErrorCondition $cond): array => ['success' => false, 'error' => $cond];
// Source validation
if (!$source_is_file) {
debug("Missing source file - {$source_file_path}");
return $fail_due_to(ProcessFileUploadErrorCondition::MissingSourceFile);
} elseif ($source_file_size === 0) {
return $fail_due_to(ProcessFileUploadErrorCondition::EmptySourceFile);
} elseif (
!is_valid_upload_path(
$source_file_path,
[...$GLOBALS['valid_upload_paths'], ini_get('upload_tmp_dir'), sys_get_temp_dir()]
)
) {
debug("[WARN] Invalid upload path detected - {$source_file_path}");
return $fail_due_to(ProcessFileUploadErrorCondition::InvalidUploadPath);
}
// Check for "special" files
if (
array_intersect(
[
'crossdomain.xml',
'clientaccesspolicy.xml',
'.htaccess',
'.htpasswd',
],
[$source_file_name]
) !== []
) {
return $fail_due_to(ProcessFileUploadErrorCondition::SpecialFile);
}
// Extension validation
$source_file_ext = parse_filename_extension($source_file_name);
if (
(
isset($processor['allow_extensions'])
&& $processor['allow_extensions'] !== []
&& !check_valid_file_extension(['name' => $source_file_name], $processor['allow_extensions'])
)
|| is_banned_extension($source_file_ext)
) {
return $fail_due_to(ProcessFileUploadErrorCondition::InvalidExtension);
}
// Check content (MIME) type based on the file received (don't trust the header from the upload)
$mime_file_based_detection = $processor['mime_file_based_detection'] ?? true;
$mime_type_by_ext = get_mime_types_by_extension($source_file_ext);
if ($mime_type_by_ext === []) {
log_activity(
"Unknown MIME type for file extension '{$source_file_ext}'",
LOG_CODE_SYSTEM,
get_mime_type($source_file_path, $source_file_ext, true)[0]
);
/* todo: Drop this overriding once we have a better MIME type database (e.g. in 3 releases from now based on the
activity log entries). This was temporarily added for v10.6 to avoid multiple failed uploads due to this new
check. */
$mime_file_based_detection = false;
}
if (
$mime_file_based_detection
&& array_intersect($mime_type_by_ext, get_mime_type($source_file_path, $source_file_ext, true)) === []
) {
debug("MIME type mismatch for file '{$source_file_name}'");
return $fail_due_to(ProcessFileUploadErrorCondition::MimeTypeMismatch);
}
// Destination processing
$dest_path = $destination->isFile() ? $destination->getRealPath() : $destination->getPathname();
if ($destination->isDir()) {
trigger_error('Destination path must be for a file, not a directory!');
exit();
} elseif (!(is_valid_rs_path($dest_path) && is_safe_basename($destination->getBasename()))) {
debug("Destination path '{$dest_path}' not allowed!");
trigger_error('Destination path not allowed');
exit();
} elseif (array_intersect(['move_uploaded_file', 'rename', 'copy'], [$file_move_processor]) === []) {
trigger_error('Invalid upload (file move) processor');
exit();
} elseif ($file_move_processor($source_file_path, $dest_path)) {
return ['success' => true];
} else {
debug("Unable to move file uploaded FROM '{$source_file_path}' TO '$dest_path'");
return $fail_due_to(ProcessFileUploadErrorCondition::FileMoveFailure);
}
}
/**
* Parse file name (can include path, although it's unnecessary) to prevent known security bypasses associated with
* extensions, such as:
* - Double extensions, e.g. .jpg.php
* - Null bytes, e.g. .php%00.jpg, where .jpg gets truncated and .php becomes the new extension
* - Using Windows (DOS) 8.3 short path feature where it's possible to replace existing files by using their shortname
* (e.g. ".htaccess" can be replaced by "HTACCE~1")
*/
function parse_filename_extension(string $filename): string
{
$orig_use_error_exception_val = $GLOBALS['use_error_exception'] ?? false;
$GLOBALS["use_error_exception"] = true;
try {
$finfo = new SplFileInfo($filename);
} catch (Throwable $t) {
debug("[WARN] Bad file '{$filename}'. Reason: {$t->getMessage()}");
return '';
}
$GLOBALS['use_error_exception'] = $orig_use_error_exception_val;
/*
Windows (DOS) 8.3 short paths (e.g. "HTACCE~1" = ".htaccess"). Example file info in such scenario:
Filename is: HTACCE~1
Path is: (note, depends if input is a file name with path)
Path name is: HTACCE~1
Real path is: C:\path\to\.htaccess
*/
if (preg_match('/^[A-Z0-9]{1,6}~([A-Z0-9]?)(\.[A-Z0-9_]{1,3})?$/i', $finfo->getFilename()) === 1) {
if ($finfo->getRealPath() !== false && basename($finfo->getRealPath()) !== $finfo->getFilename()) {
return parse_filename_extension($finfo->getRealPath());
} else {
// Invalid if not a real file to avoid potential exploits
debug("[WARN] Windows (DOS) 8.3 short path for non-existent file '{$filename}' - considered invalid");
return '';
}
}
// Invalidate if it's a hidden file without an extension (e.g .htaccess or .htpasswd) which would be incorrectly
// picked up as an extension
if (trim($finfo->getBasename($finfo->getExtension())) === '.') {
debug("Hidden file '{$filename}' without an extension - considered invalid");
return '';
}
return $finfo->getExtension();
}
/**
* Delete old files and folders from tempo directory based on the configured $purge_temp_folder_age value
* Affects filestore/tmp, $storagedir/tmp or the configured $tempdir directory
*/
function delete_temp_files(): void
{
if ($GLOBALS["purge_temp_folder_age"] === 0) {
// Disabled
return;
}
// Set up array of folders to scan
$folderstoscan = [];
$folderstoscan[] = get_temp_dir();
$modified_folderstoscan = hook("add_folders_to_delete_from_temp", "", array($folderstoscan));
if (is_array($modified_folderstoscan) && !empty($modified_folderstoscan)) {
$folderstoscan = $modified_folderstoscan;
}
// Set up array of folders to exclude
$excludepaths = [];
if (isset($GLOBALS["geo_tile_cache_directory"])) {
$excludepaths[] = $GLOBALS["geo_tile_cache_directory"];
} else {
$excludepaths[] = get_temp_dir() . "tiles";
}
if (DOWNLOAD_FILE_LIFETIME > $GLOBALS["purge_temp_folder_age"]) {
$excludepaths[] = get_temp_dir(false, "user_downloads");
}
// Set up arrays to hold items to delete
$folderstodelete = [];
$filestodelete = [];
foreach ($folderstoscan as $foldertoscan) {
if (!file_exists($foldertoscan)) {
continue;
}
$foldercontents = new DirectoryIterator($foldertoscan);
foreach ($foldercontents as $object) {
if (time() - $object->getMTime() > $GLOBALS["purge_temp_folder_age"] * 24 * 60 * 60) {
$tmpfilename = $object->getFilename();
if ($object->isDot()) {
continue;
}
foreach ($excludepaths as $excludepath) {
if (
($tmpfilename == $excludepath)
|| strpos($object->getRealPath(), $excludepath) == 0
) {
continue 2;
}
}
if ($object->isDir()) {
$folderstodelete[] = $foldertoscan . DIRECTORY_SEPARATOR . $tmpfilename;
} elseif ($object->isFile()) {
$filestodelete[] = $foldertoscan . DIRECTORY_SEPARATOR . $tmpfilename;
}
}
}
}
foreach ($folderstodelete as $foldertodelete) {
// Extra check that folder is in an expected path
if (strpos($foldertodelete, $GLOBALS["storagedir"]) === false
&& strpos($foldertodelete, $GLOBALS["tempdir"]) === false
&& strpos($foldertodelete, 'filestore/tmp') === false
) {
continue;
}
$success = rcRmdir($foldertodelete);
if ('cli' == PHP_SAPI) {
echo " - deleting directory " . $foldertodelete . " - " . ($success ? "SUCCESS" : "FAILED") . PHP_EOL;
}
debug(" - deleting directory " . $foldertodelete . " - " . ($success ? "SUCCESS" : "FAILED"));
}
foreach ($filestodelete as $filetodelete) {
// Extra check that file is in an expected path
if (strpos($filetodelete, $GLOBALS["storagedir"]) === false
&& strpos($filetodelete, $GLOBALS["tempdir"]) === false
&& strpos($filetodelete, 'filestore/tmp') === false
) {
continue;
}
$success = try_unlink($filetodelete);
if ('cli' == PHP_SAPI) {
echo " - deleting file " . $filetodelete . " - " . ($success ? "SUCCESS" : "FAILED") . PHP_EOL;
}
debug(" - deleting file " . $filetodelete . " - " . ($success ? "SUCCESS" : "FAILED"));
}
}
/**
* Are the arguments set in $archiver_settings["arguments"] permitted?
* Allows word characters, '@', and '-' only
*
* @param string Argument string
*
*/
function permitted_archiver_arguments($string): bool
{
return preg_match('/[^\@\-\w]/', $string) === 0;
}
/**
* Check if a given path is absolute or contains a symlink or junction
* is_link() does not accurately detect junction links on Windows systems
* instead we check if the output from stat() and lstat() differ.
*
* @param string $checkpath
* @return bool
*/
function check_symlink(string $checkpath): bool
{
if ($GLOBALS["config_windows"]) {
return stat($checkpath) != lstat($checkpath);
} else {
return is_link($checkpath);
}
}