582 lines
13 KiB
PHP
582 lines
13 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace Kirby\Filesystem;
|
||
|
|
|
||
|
|
use IntlDateFormatter;
|
||
|
|
use Kirby\Cms\App;
|
||
|
|
use Kirby\Exception\Exception;
|
||
|
|
use Kirby\Exception\InvalidArgumentException;
|
||
|
|
use Kirby\Http\Response;
|
||
|
|
use Kirby\Sane\Sane;
|
||
|
|
use Kirby\Toolkit\Escape;
|
||
|
|
use Kirby\Toolkit\Html;
|
||
|
|
use Kirby\Toolkit\V;
|
||
|
|
use Stringable;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Flexible File object with a set of helpful
|
||
|
|
* methods to inspect and work with files.
|
||
|
|
*
|
||
|
|
* @since 3.6.0
|
||
|
|
*
|
||
|
|
* @package Kirby Filesystem
|
||
|
|
* @author Nico Hoffmann <nico@getkirby.com>
|
||
|
|
* @link https://getkirby.com
|
||
|
|
* @copyright Bastian Allgeier
|
||
|
|
* @license https://getkirby.com/license
|
||
|
|
*/
|
||
|
|
class File implements Stringable
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* Parent file model
|
||
|
|
* The model object must use the `\Kirby\Filesystem\IsFile` trait
|
||
|
|
*/
|
||
|
|
protected object|null $model;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Absolute file path
|
||
|
|
*/
|
||
|
|
protected string|null $root;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Absolute file URL
|
||
|
|
*/
|
||
|
|
protected string|null $url;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validation rules to be used for `::match()`
|
||
|
|
*/
|
||
|
|
public static array $validations = [
|
||
|
|
'maxsize' => ['size', 'max'],
|
||
|
|
'minsize' => ['size', 'min']
|
||
|
|
];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Constructor sets all file properties
|
||
|
|
*
|
||
|
|
* @param array|string|null $props Properties or deprecated `$root` string
|
||
|
|
* @param string|null $url Deprecated argument, use `$props['url']` instead
|
||
|
|
*
|
||
|
|
* @throws \Kirby\Exception\InvalidArgumentException When the model does not use the `Kirby\Filesystem\IsFile` trait
|
||
|
|
*/
|
||
|
|
public function __construct(
|
||
|
|
array|string|null $props = null,
|
||
|
|
string|null $url = null
|
||
|
|
) {
|
||
|
|
// Legacy support for old constructor of
|
||
|
|
// the `Kirby\Image\Image` class
|
||
|
|
if (is_array($props) === false) {
|
||
|
|
$props = [
|
||
|
|
'root' => $props,
|
||
|
|
'url' => $url
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->root = $props['root'] ?? null;
|
||
|
|
$this->url = $props['url'] ?? null;
|
||
|
|
$this->model = $props['model'] ?? null;
|
||
|
|
|
||
|
|
if (
|
||
|
|
$this->model !== null &&
|
||
|
|
method_exists($this->model, 'hasIsFileTrait') !== true
|
||
|
|
) {
|
||
|
|
throw new InvalidArgumentException(
|
||
|
|
message: 'The model object must use the "Kirby\Filesystem\IsFile" trait'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Improved `var_dump` output
|
||
|
|
* @codeCoverageIgnore
|
||
|
|
*/
|
||
|
|
public function __debugInfo(): array
|
||
|
|
{
|
||
|
|
return $this->toArray();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the URL for the file object
|
||
|
|
*/
|
||
|
|
public function __toString(): string
|
||
|
|
{
|
||
|
|
return $this->url() ?? $this->root() ?? '';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the file content as base64 encoded string
|
||
|
|
*/
|
||
|
|
public function base64(): string
|
||
|
|
{
|
||
|
|
return base64_encode($this->read());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Copy a file to a new location.
|
||
|
|
*/
|
||
|
|
public function copy(string $target, bool $force = false): static
|
||
|
|
{
|
||
|
|
if (F::copy($this->root(), $target, $force) !== true) {
|
||
|
|
throw new Exception(
|
||
|
|
message: 'The file "' . $this->root() . '" could not be copied'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return new static($target);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the file as data uri
|
||
|
|
*
|
||
|
|
* @param bool $base64 Whether the data should be base64 encoded or not
|
||
|
|
*/
|
||
|
|
public function dataUri(bool $base64 = true): string
|
||
|
|
{
|
||
|
|
return match ($base64) {
|
||
|
|
true => 'data:' . $this->mime() . ';base64,' . $this->base64(),
|
||
|
|
false => 'data:' . $this->mime() . ',' . Escape::url($this->read())
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Deletes the file
|
||
|
|
*/
|
||
|
|
public function delete(): bool
|
||
|
|
{
|
||
|
|
if (F::remove($this->root()) !== true) {
|
||
|
|
throw new Exception(
|
||
|
|
message: 'The file "' . $this->root() . '" could not be deleted'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Automatically sends all needed headers
|
||
|
|
* for the file to be downloaded and
|
||
|
|
* echos the file's content
|
||
|
|
*
|
||
|
|
* @param string|null $filename Optional filename for the download
|
||
|
|
*/
|
||
|
|
public function download(string|null $filename = null): string
|
||
|
|
{
|
||
|
|
return Response::download(
|
||
|
|
$this->root(),
|
||
|
|
$filename ?? $this->filename()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Checks if the file actually exists
|
||
|
|
*/
|
||
|
|
public function exists(): bool
|
||
|
|
{
|
||
|
|
return file_exists($this->root()) === true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the current lowercase extension (without .)
|
||
|
|
*/
|
||
|
|
public function extension(): string
|
||
|
|
{
|
||
|
|
return F::extension($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the filename
|
||
|
|
*/
|
||
|
|
public function filename(): string
|
||
|
|
{
|
||
|
|
return basename($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns a md5 hash of the root
|
||
|
|
*/
|
||
|
|
public function hash(): string
|
||
|
|
{
|
||
|
|
return md5($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sends an appropriate header for the asset
|
||
|
|
*/
|
||
|
|
public function header(bool $send = true): Response|null
|
||
|
|
{
|
||
|
|
$response = new Response('', $this->mime());
|
||
|
|
|
||
|
|
if ($send !== true) {
|
||
|
|
return $response;
|
||
|
|
}
|
||
|
|
|
||
|
|
$response->send();
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Converts the file to html
|
||
|
|
*/
|
||
|
|
public function html(array $attr = []): string
|
||
|
|
{
|
||
|
|
return Html::a($this->url() ?? '', $attr);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Checks if a file is of a certain type
|
||
|
|
*
|
||
|
|
* @param string $value An extension or mime type
|
||
|
|
*/
|
||
|
|
public function is(string $value): bool
|
||
|
|
{
|
||
|
|
return F::is($this->root(), $value);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Checks if the file is readable
|
||
|
|
*/
|
||
|
|
public function isReadable(): bool
|
||
|
|
{
|
||
|
|
return is_readable($this->root()) === true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Checks if the file is a resizable image
|
||
|
|
*/
|
||
|
|
public function isResizable(): bool
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Checks if a preview can be displayed for the file
|
||
|
|
* in the Panel or in the frontend
|
||
|
|
*/
|
||
|
|
public function isViewable(): bool
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Checks if the file is writable
|
||
|
|
*/
|
||
|
|
public function isWritable(): bool
|
||
|
|
{
|
||
|
|
return F::isWritable($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the app instance if it exists
|
||
|
|
*/
|
||
|
|
public function kirby(): App|null
|
||
|
|
{
|
||
|
|
return App::instance(null, true);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Runs a set of validations on the file object
|
||
|
|
* (mainly for images).
|
||
|
|
*
|
||
|
|
* @throws \Kirby\Exception\Exception
|
||
|
|
*/
|
||
|
|
public function match(array $rules): bool
|
||
|
|
{
|
||
|
|
$rules = array_change_key_case($rules);
|
||
|
|
|
||
|
|
if (is_array($rules['mime'] ?? null) === true) {
|
||
|
|
$mime = $this->mime();
|
||
|
|
|
||
|
|
// the MIME type could not be determined, but matching
|
||
|
|
// to it was requested explicitly
|
||
|
|
if ($mime === null) {
|
||
|
|
throw new Exception(
|
||
|
|
key: 'file.mime.missing',
|
||
|
|
data: ['filename' => $this->filename()]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// determine if any pattern matches the MIME type;
|
||
|
|
// once any pattern matches, `$carry` is `true` and the rest is skipped
|
||
|
|
$matches = array_reduce(
|
||
|
|
$rules['mime'],
|
||
|
|
fn ($carry, $pattern) => $carry || Mime::matches($mime, $pattern),
|
||
|
|
false
|
||
|
|
);
|
||
|
|
|
||
|
|
if ($matches !== true) {
|
||
|
|
throw new Exception(
|
||
|
|
key: 'file.mime.invalid',
|
||
|
|
data: compact('mime')
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (is_array($rules['extension'] ?? null) === true) {
|
||
|
|
$extension = $this->extension();
|
||
|
|
if (in_array($extension, $rules['extension'], true) !== true) {
|
||
|
|
throw new Exception(
|
||
|
|
key: 'file.extension.invalid',
|
||
|
|
data: compact('extension')
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (is_array($rules['type'] ?? null) === true) {
|
||
|
|
$type = $this->type();
|
||
|
|
if (in_array($type, $rules['type'], true) !== true) {
|
||
|
|
throw new Exception(
|
||
|
|
key: 'file.type.invalid',
|
||
|
|
data: compact('type')
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (static::$validations as $key => $arguments) {
|
||
|
|
$rule = $rules[$key] ?? null;
|
||
|
|
|
||
|
|
if ($rule !== null) {
|
||
|
|
$property = $arguments[0];
|
||
|
|
$validator = $arguments[1];
|
||
|
|
|
||
|
|
if (V::$validator($this->$property(), $rule) === false) {
|
||
|
|
throw new Exception(
|
||
|
|
key: 'file.' . $key,
|
||
|
|
data: [$property => $rule]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Detects the mime type of the file
|
||
|
|
*/
|
||
|
|
public function mime(): string|null
|
||
|
|
{
|
||
|
|
return Mime::type($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the parent file model, which uses this instance as proxied file asset
|
||
|
|
*/
|
||
|
|
public function model(): object|null
|
||
|
|
{
|
||
|
|
return $this->model;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the file's last modification time
|
||
|
|
*
|
||
|
|
* @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null`
|
||
|
|
* for the globally configured one
|
||
|
|
*/
|
||
|
|
public function modified(
|
||
|
|
string|IntlDateFormatter|null $format = null,
|
||
|
|
string|null $handler = null
|
||
|
|
): string|int|false {
|
||
|
|
return F::modified($this->root(), $format, $handler);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Move the file to a new location
|
||
|
|
*
|
||
|
|
* @param bool $overwrite Force overwriting any existing files
|
||
|
|
*/
|
||
|
|
public function move(string $newRoot, bool $overwrite = false): static
|
||
|
|
{
|
||
|
|
if (F::move($this->root(), $newRoot, $overwrite) !== true) {
|
||
|
|
throw new Exception(
|
||
|
|
message: 'The file: "' . $this->root() . '" could not be moved to: "' . $newRoot . '"'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return new static($newRoot);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Getter for the name of the file
|
||
|
|
* without the extension
|
||
|
|
*/
|
||
|
|
public function name(): string
|
||
|
|
{
|
||
|
|
return pathinfo($this->root(), PATHINFO_FILENAME);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the file size in a
|
||
|
|
* human-readable format
|
||
|
|
*
|
||
|
|
* @param string|false|null $locale Locale for number formatting,
|
||
|
|
* `null` for the current locale,
|
||
|
|
* `false` to disable number formatting
|
||
|
|
*/
|
||
|
|
public function niceSize(string|false|null $locale = null): string
|
||
|
|
{
|
||
|
|
return F::niceSize($this->root(), $locale);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reads the file content and returns it.
|
||
|
|
*/
|
||
|
|
public function read(): string|false
|
||
|
|
{
|
||
|
|
return F::read($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the absolute path to the file
|
||
|
|
*/
|
||
|
|
public function realpath(): string
|
||
|
|
{
|
||
|
|
return realpath($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Changes the name of the file without
|
||
|
|
* touching the extension
|
||
|
|
*
|
||
|
|
* @param bool $overwrite Force overwrite existing files
|
||
|
|
*/
|
||
|
|
public function rename(string $newName, bool $overwrite = false): static
|
||
|
|
{
|
||
|
|
$newRoot = F::rename($this->root(), $newName, $overwrite);
|
||
|
|
|
||
|
|
if ($newRoot === false) {
|
||
|
|
throw new Exception(
|
||
|
|
message: 'The file: "' . $this->root() . '" could not be renamed to: "' . $newName . '"'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return new static($newRoot);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the given file path
|
||
|
|
*/
|
||
|
|
public function root(): string|null
|
||
|
|
{
|
||
|
|
return $this->root ??= $this->model?->root();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the absolute url for the file
|
||
|
|
*/
|
||
|
|
public function url(): string|null
|
||
|
|
{
|
||
|
|
// lazily determine the URL from the model object
|
||
|
|
// only if it's needed to avoid breaking custom file::url
|
||
|
|
// components that rely on `$cmsFile->asset()` methods
|
||
|
|
return $this->url ??= $this->model?->url();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sanitizes the file contents depending on the file type
|
||
|
|
* by overwriting the file with the sanitized version
|
||
|
|
* @since 3.6.0
|
||
|
|
*
|
||
|
|
* @param string|bool $typeLazy Explicit sane handler type string,
|
||
|
|
* `true` for lazy autodetection or
|
||
|
|
* `false` for normal autodetection
|
||
|
|
*
|
||
|
|
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
|
||
|
|
* @throws \Kirby\Exception\LogicException If more than one handler applies
|
||
|
|
* @throws \Kirby\Exception\NotFoundException If the handler was not found
|
||
|
|
* @throws \Kirby\Exception\Exception On other errors
|
||
|
|
*/
|
||
|
|
public function sanitizeContents(string|bool $typeLazy = false): void
|
||
|
|
{
|
||
|
|
Sane::sanitizeFile($this->root(), $typeLazy);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the sha1 hash of the file
|
||
|
|
* @since 3.6.0
|
||
|
|
*/
|
||
|
|
public function sha1(): string
|
||
|
|
{
|
||
|
|
return sha1_file($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the raw size of the file
|
||
|
|
*/
|
||
|
|
public function size(): int
|
||
|
|
{
|
||
|
|
return F::size($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Converts the media object to a
|
||
|
|
* plain PHP array
|
||
|
|
*/
|
||
|
|
public function toArray(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'extension' => $this->extension(),
|
||
|
|
'filename' => $this->filename(),
|
||
|
|
'hash' => $this->hash(),
|
||
|
|
'isReadable' => $this->isReadable(),
|
||
|
|
'isResizable' => $this->isResizable(),
|
||
|
|
'isWritable' => $this->isWritable(),
|
||
|
|
'mime' => $this->mime(),
|
||
|
|
'modified' => $this->modified('c'),
|
||
|
|
'name' => $this->name(),
|
||
|
|
'niceSize' => $this->niceSize(),
|
||
|
|
'root' => $this->root(),
|
||
|
|
'safeName' => F::safeName($this->name()),
|
||
|
|
'size' => $this->size(),
|
||
|
|
'type' => $this->type(),
|
||
|
|
'url' => $this->url()
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Converts the entire file array into
|
||
|
|
* a json string
|
||
|
|
*/
|
||
|
|
public function toJson(): string
|
||
|
|
{
|
||
|
|
return json_encode($this->toArray());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the file type.
|
||
|
|
*/
|
||
|
|
public function type(): string|null
|
||
|
|
{
|
||
|
|
return F::type($this->root());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validates the file contents depending on the file type
|
||
|
|
*
|
||
|
|
* @param string|bool $typeLazy Explicit sane handler type string,
|
||
|
|
* `true` for lazy autodetection or
|
||
|
|
* `false` for normal autodetection
|
||
|
|
*
|
||
|
|
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
|
||
|
|
* @throws \Kirby\Exception\NotFoundException If the handler was not found
|
||
|
|
* @throws \Kirby\Exception\Exception On other errors
|
||
|
|
*/
|
||
|
|
public function validateContents(string|bool $typeLazy = false): void
|
||
|
|
{
|
||
|
|
Sane::validateFile($this->root(), $typeLazy);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Writes content to the file
|
||
|
|
*/
|
||
|
|
public function write(string $content): bool
|
||
|
|
{
|
||
|
|
if (F::write($this->root(), $content) !== true) {
|
||
|
|
throw new Exception(
|
||
|
|
message: 'The file "' . $this->root() . '" could not be written'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|