designtopack/public/kirby/src/Filesystem/F.php
2024-07-10 16:10:33 +02:00

943 lines
20 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Kirby\Filesystem;
use Exception;
use IntlDateFormatter;
use Kirby\Cms\Helpers;
use Kirby\Http\Response;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
use Throwable;
use ZipArchive;
/**
* The `F` class provides methods for
* dealing with files on the file system
* level, like creating, reading,
* deleting, copying or validatings files.
*
* @package Kirby Filesystem
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class F
{
public static array $types = [
'archive' => [
'gz',
'gzip',
'tar',
'tgz',
'zip',
],
'audio' => [
'aif',
'aiff',
'm4a',
'midi',
'mp3',
'wav',
],
'code' => [
'css',
'js',
'json',
'java',
'htm',
'html',
'php',
'rb',
'py',
'scss',
'xml',
'yaml',
'yml',
],
'document' => [
'csv',
'doc',
'docx',
'dotx',
'indd',
'md',
'mdown',
'pdf',
'ppt',
'pptx',
'rtf',
'txt',
'xl',
'xls',
'xlsx',
'xltx',
],
'image' => [
'ai',
'avif',
'bmp',
'gif',
'eps',
'ico',
'j2k',
'jp2',
'jpeg',
'jpg',
'jpe',
'png',
'ps',
'psd',
'svg',
'tif',
'tiff',
'webp'
],
'video' => [
'avi',
'flv',
'm4v',
'mov',
'movie',
'mpe',
'mpg',
'mp4',
'ogg',
'ogv',
'swf',
'webm',
],
];
public static array $units = [
'B',
'KB',
'MB',
'GB',
'TB',
'PB',
'EB',
'ZB',
'YB'
];
/**
* Appends new content to an existing file
*
* @param string $file The path for the file
* @param mixed $content Either a string or an array. Arrays will be converted to JSON.
*/
public static function append(string $file, $content): bool
{
return static::write($file, $content, true);
}
/**
* Returns the file content as base64 encoded string
*
* @param string $file The path for the file
*/
public static function base64(string $file): string
{
return base64_encode(static::read($file));
}
/**
* Copy a file to a new location.
*/
public static function copy(string $source, string $target, bool $force = false): bool
{
if (file_exists($source) === false || (file_exists($target) === true && $force === false)) {
return false;
}
$directory = dirname($target);
// create the parent directory if it does not exist
if (is_dir($directory) === false) {
Dir::make($directory, true);
}
return copy($source, $target);
}
/**
* Just an alternative for dirname() to stay consistent
*
* <code>
*
* $dirname = F::dirname('/var/www/test.txt');
* // dirname is /var/www
*
* </code>
*
* @param string $file The path
*/
public static function dirname(string $file): string
{
return dirname($file);
}
/**
* Checks if the file exists on disk
*/
public static function exists(string $file, string|null $in = null): bool
{
try {
static::realpath($file, $in);
return true;
} catch (Exception) {
return false;
}
}
/**
* Gets the extension of a file
*
* @param string|null $file The filename or path
* @param string|null $extension Set an optional extension to overwrite the current one
*/
public static function extension(
string|null $file = null,
string|null $extension = null
): string {
// overwrite the current extension
if ($extension !== null) {
return static::name($file) . '.' . $extension;
}
// return the current extension
return Str::lower(pathinfo($file, PATHINFO_EXTENSION));
}
/**
* Converts a file extension to a mime type
*/
public static function extensionToMime(string $extension): string|null
{
return Mime::fromExtension($extension);
}
/**
* Returns the file type for a passed extension
*/
public static function extensionToType(string $extension): string|false
{
foreach (static::$types as $type => $extensions) {
if (in_array($extension, $extensions) === true) {
return $type;
}
}
return false;
}
/**
* Returns all extensions for a certain file type
*/
public static function extensions(string|null $type = null): array
{
if ($type === null) {
return array_keys(Mime::types());
}
return static::$types[$type] ?? [];
}
/**
* Extracts the filename from a file path
*
* <code>
*
* $filename = F::filename('/var/www/test.txt');
* // filename is test.txt
*
* </code>
*
* @param string $name The path
*/
public static function filename(string $name): string
{
return pathinfo($name, PATHINFO_BASENAME);
}
/**
* Invalidate opcode cache for file.
*
* @param string $file The path of the file
*/
public static function invalidateOpcodeCache(string $file): bool
{
if (
function_exists('opcache_invalidate') &&
strlen(ini_get('opcache.restrict_api')) === 0
) {
return opcache_invalidate($file, true);
}
return false;
}
/**
* Checks if a file is of a certain type
*
* @param string $file Full path to the file
* @param string $value An extension or mime type
*/
public static function is(string $file, string $value): bool
{
// check for the extension
if (in_array($value, static::extensions()) === true) {
return static::extension($file) === $value;
}
// check for the mime type
if (strpos($value, '/') !== false) {
return static::mime($file) === $value;
}
return false;
}
/**
* Checks if the file is readable
*/
public static function isReadable(string $file): bool
{
return is_readable($file);
}
/**
* Checks if the file is writable
*/
public static function isWritable(string $file): bool
{
if (file_exists($file) === false) {
return is_writable(dirname($file));
}
return is_writable($file);
}
/**
* Create a (symbolic) link to a file
*/
public static function link(string $source, string $link, string $method = 'link'): bool
{
Dir::make(dirname($link), true);
if (is_file($link) === true) {
return true;
}
if (is_file($source) === false) {
throw new Exception(sprintf('The file "%s" does not exist and cannot be linked', $source));
}
try {
return $method($source, $link) === true;
} catch (Throwable) {
return false;
}
}
/**
* Loads a file and returns the result or `false` if the
* file to load does not exist
*
* @param array $data Optional array of variables to extract in the variable scope
*/
public static function load(
string $file,
mixed $fallback = null,
array $data = [],
bool $allowOutput = true
) {
if (is_file($file) === false) {
return $fallback;
}
// we use the loadIsolated() method here to prevent the included
// file from overwriting our $fallback in this variable scope; see
// https://www.php.net/manual/en/function.include.php#example-124
$callback = fn () => static::loadIsolated($file, $data);
// if the loaded file should not produce any output,
// call the loaidIsolated method from the Response class
// which checks for unintended ouput and throws an error if detected
if ($allowOutput === false) {
$result = Response::guardAgainstOutput($callback);
} else {
$result = $callback();
}
if (
$fallback !== null &&
gettype($result) !== gettype($fallback)
) {
return $fallback;
}
return $result;
}
/**
* A super simple class autoloader
* @since 3.7.0
*/
public static function loadClasses(
array $classmap,
string|null $base = null
): void {
// convert all classnames to lowercase
$classmap = array_change_key_case($classmap);
spl_autoload_register(
fn ($class) => Response::guardAgainstOutput(function () use ($class, $classmap, $base) {
$class = strtolower($class);
if (isset($classmap[$class]) === false) {
return false;
}
if ($base) {
include $base . '/' . $classmap[$class];
} else {
include $classmap[$class];
}
})
);
}
/**
* Loads a file with as little as possible in the variable scope
*
* @param array $data Optional array of variables to extract in the variable scope
*/
protected static function loadIsolated(string $file, array $data = [])
{
// extract the $data variables in this scope to be accessed by the included file;
// protect $file against being overwritten by a $data variable
$___file___ = $file;
extract($data);
return include $___file___;
}
/**
* Loads a file using `include_once()` and
* returns whether loading was successful
*/
public static function loadOnce(
string $file,
bool $allowOutput = true
): bool {
if (is_file($file) === false) {
return false;
}
$callback = fn () => include_once $file;
if ($allowOutput === false) {
Response::guardAgainstOutput($callback);
} else {
$callback();
}
return true;
}
/**
* Returns the mime type of a file
*/
public static function mime(string $file): string|null
{
return Mime::type($file);
}
/**
* Converts a mime type to a file extension
*/
public static function mimeToExtension(string|null $mime = null): string|false
{
return Mime::toExtension($mime);
}
/**
* Returns the type for a given mime
*/
public static function mimeToType(string $mime): string|false
{
return static::extensionToType(Mime::toExtension($mime));
}
/**
* Get the file's last modification time.
*
* @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null`
* for the globally configured one
*/
public static function modified(
string $file,
string|IntlDateFormatter|null $format = null,
string|null $handler = null
): string|int|false {
if (file_exists($file) !== true) {
return false;
}
$modified = filemtime($file);
return Str::date($modified, $format, $handler);
}
/**
* Moves a file to a new location
*
* @param string $oldRoot The current path for the file
* @param string $newRoot The path to the new location
* @param bool $force Force move if the target file exists
*/
public static function move(string $oldRoot, string $newRoot, bool $force = false): bool
{
// check if the file exists
if (file_exists($oldRoot) === false) {
return false;
}
if (file_exists($newRoot) === true) {
if ($force === false) {
return false;
}
// delete the existing file
static::remove($newRoot);
}
$directory = dirname($newRoot);
// create the parent directory if it does not exist
if (is_dir($directory) === false) {
Dir::make($directory, true);
}
// atomically moving the file will only work if
// source and target are on the same filesystem
if (stat($oldRoot)['dev'] === stat($directory)['dev']) {
// same filesystem, we can move the file
return rename($oldRoot, $newRoot) === true;
}
// @codeCoverageIgnoreStart
// not the same filesystem; we need to copy
// the file and unlink the source afterwards
if (copy($oldRoot, $newRoot) === true) {
return unlink($oldRoot) === true;
}
// copying failed, ensure the new root isn't there
// (e.g. if the file could be created but there's no
// more remaining disk space to write its contents)
static::remove($newRoot);
return false;
// @codeCoverageIgnoreEnd
}
/**
* Extracts the name from a file path or filename without extension
*
* @param string $name The path or filename
*/
public static function name(string $name): string
{
return pathinfo($name, PATHINFO_FILENAME);
}
/**
* Converts an integer size into a human readable format
*
* @param int|string|array $size The file size, a file path or array of paths
* @param string|false|null $locale Locale for number formatting,
* `null` for the current locale,
* `false` to disable number formatting
*/
public static function niceSize(
int|string|array $size,
string|false|null $locale = null
): string {
// file mode
if (is_string($size) === true || is_array($size) === true) {
$size = static::size($size);
}
// make sure it's an int
$size = (int)$size;
// avoid errors for invalid sizes
if ($size <= 0) {
return '0 KB';
}
// the math magic
$size = round($size / 1024 ** ($unit = floor(log($size, 1024))), 2);
// format the number if requested
if ($locale !== false) {
$size = I18n::formatNumber($size, $locale);
}
return $size . ' ' . static::$units[$unit];
}
/**
* Reads the content of a file or requests the
* contents of a remote HTTP or HTTPS URL
*
* @param string $file The path for the file or an absolute URL
*/
public static function read(string $file): string|false
{
if (
is_readable($file) !== true &&
Str::startsWith($file, 'https://') !== true &&
Str::startsWith($file, 'http://') !== true
) {
return false;
}
return file_get_contents($file);
}
/**
* Changes the name of the file without
* touching the extension
*
* @param bool $overwrite Force overwrite existing files
*/
public static function rename(string $file, string $newName, bool $overwrite = false): string|false
{
// create the new name
$name = static::safeName(basename($newName));
// overwrite the root
$newRoot = rtrim(dirname($file) . '/' . $name . '.' . F::extension($file), '.');
// nothing has changed
if ($newRoot === $file) {
return $newRoot;
}
if (F::move($file, $newRoot, $overwrite) !== true) {
return false;
}
return $newRoot;
}
/**
* Returns the absolute path to the file if the file can be found.
*/
public static function realpath(string $file, string|null $in = null): string
{
$realpath = realpath($file);
if ($realpath === false || is_file($realpath) === false) {
throw new Exception(sprintf('The file does not exist at the given path: "%s"', $file));
}
if ($in !== null) {
$parent = realpath($in);
if ($parent === false || is_dir($parent) === false) {
throw new Exception(sprintf('The parent directory does not exist: "%s"', $in));
}
if (substr($realpath, 0, strlen($parent)) !== $parent) {
throw new Exception('The file is not within the parent directory');
}
}
return $realpath;
}
/**
* Returns the relative path of the file
* starting after $in
*
* @SuppressWarnings(PHPMD.CountInLoopExpression)
*/
public static function relativepath(string $file, string|null $in = null): string
{
if (empty($in) === true) {
return basename($file);
}
// windows
$file = str_replace('\\', '/', $file);
$in = str_replace('\\', '/', $in);
// trim trailing slashes
$file = rtrim($file, '/');
$in = rtrim($in, '/');
if (Str::contains($file, $in . '/') === false) {
// make the paths relative by stripping what they have
// in common and adding `../` tokens at the start
$fileParts = explode('/', $file);
$inParts = explode('/', $in);
while (count($fileParts) && count($inParts) && ($fileParts[0] === $inParts[0])) {
array_shift($fileParts);
array_shift($inParts);
}
return str_repeat('../', count($inParts)) . implode('/', $fileParts);
}
return '/' . Str::after($file, $in . '/');
}
/**
* Deletes a file
*
* <code>
*
* $remove = F::remove('test.txt');
* if($remove) echo 'The file has been removed';
*
* </code>
*
* @param string $file The path for the file
*/
public static function remove(string $file): bool
{
if (strpos($file, '*') !== false) {
foreach (glob($file) as $f) {
static::remove($f);
}
return true;
}
$file = realpath($file);
if (is_string($file) === false) {
return true;
}
return static::unlink($file);
}
/**
* Sanitize a file's full name (filename and extension)
* to strip unwanted special characters
*
* <code>
*
* $safe = f::safeName('über genius.txt');
* // safe will be ueber-genius.txt
*
* </code>
*
* @param string $string The file name
*/
public static function safeName(string $string): string
{
$basename = static::safeBasename($string);
$extension = static::safeExtension($string);
if (empty($extension) === false) {
$extension = '.' . $extension;
}
return $basename . $extension;
}
/**
* Sanitize a file's name (without extension)
* @since 4.0.0
*/
public static function safeBasename(
string $string,
bool $extract = true
): string {
// extract only the name part from whole filename string
if ($extract === true) {
$string = static::name($string);
}
return Str::slug($string, '-', 'a-z0-9@._-');
}
/**
* Sanitize a file's extension
* @since 4.0.0
*/
public static function safeExtension(
string $string,
bool $extract = true
): string {
// extract only the extension part from whole filename string
if ($extract === true) {
$string = static::extension($string);
}
return Str::slug($string);
}
/**
* Tries to find similar or the same file by
* building a glob based on the path
*/
public static function similar(string $path, string $pattern = '*'): array
{
$dir = dirname($path);
$name = static::name($path);
$extension = static::extension($path);
$glob = $dir . '/' . $name . $pattern . '.' . $extension;
return glob($glob);
}
/**
* Returns the size of a file or an array of files.
*
* @param string|array $file file path or array of paths
*/
public static function size(string|array $file): int
{
if (is_array($file) === true) {
return array_reduce(
$file,
fn ($total, $file) => $total + F::size($file),
0
);
}
if ($size = @filesize($file)) {
return $size;
}
return 0;
}
/**
* Categorize the file
*
* @param string $file Either the file path or extension
*/
public static function type(string $file): string|null
{
$length = strlen($file);
if ($length >= 2 && $length <= 4) {
// use the file name as extension
$extension = $file;
} else {
// get the extension from the filename
$extension = pathinfo($file, PATHINFO_EXTENSION);
}
if (empty($extension) === true) {
// detect the mime type first to get the most reliable extension
$mime = static::mime($file);
$extension = static::mimeToExtension($mime);
}
// sanitize extension
$extension = strtolower($extension);
foreach (static::$types as $type => $extensions) {
if (in_array($extension, $extensions) === true) {
return $type;
}
}
return null;
}
/**
* Returns all extensions of a given file type
* or `null` if the file type is unknown
*/
public static function typeToExtensions(string $type): array|null
{
return static::$types[$type] ?? null;
}
/**
* Ensures that a file or link is deleted (with race condition handling)
* @since 3.7.4
*/
public static function unlink(string $file): bool
{
return Helpers::handleErrors(
fn (): bool => unlink($file),
// if the file or link was already deleted (race condition),
fn (int $errno, string $errstr): bool => Str::endsWith($errstr, 'No such file or directory'),
// consider it a success
true
);
}
/**
* Unzips a zip file
*/
public static function unzip(string $file, string $to): bool
{
if (class_exists('ZipArchive') === false) {
throw new Exception('The ZipArchive class is not available');
}
$zip = new ZipArchive();
if ($zip->open($file) === true) {
$zip->extractTo($to);
$zip->close();
return true;
}
return false;
}
/**
* Returns the file as data uri
*
* @param string $file The path for the file
*/
public static function uri(string $file): string|false
{
if ($mime = static::mime($file)) {
return 'data:' . $mime . ';base64,' . static::base64($file);
}
return false;
}
/**
* Creates a new file
*
* @param string $file The path for the new file
* @param mixed $content Either a string, an object or an array. Arrays and objects will be serialized.
* @param bool $append true: append the content to an existing file if available. false: overwrite.
*/
public static function write(string $file, $content, bool $append = false): bool
{
if (is_array($content) === true || is_object($content) === true) {
$content = serialize($content);
}
$mode = $append === true ? FILE_APPEND | LOCK_EX : LOCK_EX;
// if the parent directory does not exist, create it
if (is_dir(dirname($file)) === false) {
if (Dir::make(dirname($file)) === false) {
return false;
}
}
if (static::isWritable($file) === false) {
throw new Exception('The file "' . $file . '" is not writable');
}
return file_put_contents($file, $content, $mode) !== false;
}
}