Initial commit
This commit is contained in:
commit
efa5624dab
687 changed files with 162710 additions and 0 deletions
622
kirby/src/Api/Api.php
Normal file
622
kirby/src/Api/Api.php
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Exception\Exception as ExceptionException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Http\Route;
|
||||
use Kirby\Http\Router;
|
||||
use Kirby\Toolkit\Collection as BaseCollection;
|
||||
use Kirby\Toolkit\Pagination;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The API class is a generic container
|
||||
* for API routes, models and collections and is used
|
||||
* to run our REST API. You can find our API setup
|
||||
* in `kirby/config/api.php`.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Api
|
||||
{
|
||||
/**
|
||||
* Authentication callback
|
||||
*/
|
||||
protected Closure|null $authentication = null;
|
||||
|
||||
/**
|
||||
* Debugging flag
|
||||
*/
|
||||
protected bool $debug = false;
|
||||
|
||||
/**
|
||||
* Collection definition
|
||||
*/
|
||||
protected array $collections = [];
|
||||
|
||||
/**
|
||||
* Injected data/dependencies
|
||||
*/
|
||||
protected array $data = [];
|
||||
|
||||
/**
|
||||
* Model definitions
|
||||
*/
|
||||
protected array $models = [];
|
||||
|
||||
/**
|
||||
* The current route
|
||||
*/
|
||||
protected Route|null $route = null;
|
||||
|
||||
/**
|
||||
* The Router instance
|
||||
*/
|
||||
protected Router|null $router = null;
|
||||
|
||||
/**
|
||||
* Route definition
|
||||
*/
|
||||
protected array $routes = [];
|
||||
|
||||
/**
|
||||
* Request data
|
||||
* [query, body, files]
|
||||
*/
|
||||
protected array $requestData = [];
|
||||
|
||||
/**
|
||||
* The applied request method
|
||||
* (GET, POST, PATCH, etc.)
|
||||
*/
|
||||
protected string|null $requestMethod = null;
|
||||
|
||||
/**
|
||||
* Creates a new API instance
|
||||
*/
|
||||
public function __construct(array $props)
|
||||
{
|
||||
$this->authentication = $props['authentication'] ?? null;
|
||||
$this->data = $props['data'] ?? [];
|
||||
$this->routes = $props['routes'] ?? [];
|
||||
$this->debug = $props['debug'] ?? false;
|
||||
|
||||
if ($collections = $props['collections'] ?? null) {
|
||||
$this->collections = array_change_key_case($collections);
|
||||
}
|
||||
|
||||
if ($models = $props['models'] ?? null) {
|
||||
$this->models = array_change_key_case($models);
|
||||
}
|
||||
|
||||
$this->setRequestData($props['requestData'] ?? null);
|
||||
$this->setRequestMethod($props['requestMethod'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic accessor for any given data
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
*/
|
||||
public function __call(string $method, array $args = [])
|
||||
{
|
||||
return $this->data($method, ...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the authentication method
|
||||
* if set
|
||||
*/
|
||||
public function authenticate()
|
||||
{
|
||||
return $this->authentication()?->call($this) ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authentication callback
|
||||
*/
|
||||
public function authentication(): Closure|null
|
||||
{
|
||||
return $this->authentication;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an API call for the given path,
|
||||
* request method and optional request data
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function call(
|
||||
string|null $path = null,
|
||||
string $method = 'GET',
|
||||
array $requestData = []
|
||||
): mixed {
|
||||
$path = rtrim($path ?? '', '/');
|
||||
|
||||
$this->setRequestMethod($method);
|
||||
$this->setRequestData($requestData);
|
||||
|
||||
$this->router = new Router($this->routes());
|
||||
$this->route = $this->router->find($path, $method);
|
||||
$auth = $this->route?->attributes()['auth'] ?? true;
|
||||
|
||||
if ($auth !== false) {
|
||||
$user = $this->authenticate();
|
||||
|
||||
// set PHP locales based on *user* language
|
||||
// so that e.g. strftime() gets formatted correctly
|
||||
if ($user instanceof User) {
|
||||
$language = $user->language();
|
||||
|
||||
// get the locale from the translation
|
||||
$locale = $user->kirby()->translation($language)->locale();
|
||||
|
||||
// provide some variants as fallbacks to be
|
||||
// compatible with as many systems as possible
|
||||
$locales = [
|
||||
$locale . '.UTF-8',
|
||||
$locale . '.UTF8',
|
||||
$locale . '.ISO8859-1',
|
||||
$locale,
|
||||
$language,
|
||||
setlocale(LC_ALL, 0) // fall back to the previously defined locale
|
||||
];
|
||||
|
||||
// set the locales that are relevant for string formatting
|
||||
// *don't* set LC_CTYPE to avoid breaking other parts of the system
|
||||
setlocale(LC_MONETARY, $locales);
|
||||
setlocale(LC_NUMERIC, $locales);
|
||||
setlocale(LC_TIME, $locales);
|
||||
}
|
||||
}
|
||||
|
||||
// don't throw pagination errors if pagination
|
||||
// page is out of bounds
|
||||
$validate = Pagination::$validate;
|
||||
Pagination::$validate = false;
|
||||
|
||||
$output = $this->route?->action()->call(
|
||||
$this,
|
||||
...$this->route->arguments()
|
||||
);
|
||||
|
||||
// restore old pagination validation mode
|
||||
Pagination::$validate = $validate;
|
||||
|
||||
if (
|
||||
is_object($output) === true &&
|
||||
$output instanceof Response === false
|
||||
) {
|
||||
return $this->resolve($output)->toResponse();
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance while
|
||||
* merging initial and new properties
|
||||
*/
|
||||
public function clone(array $props = []): static
|
||||
{
|
||||
return new static([
|
||||
'autentication' => $this->authentication,
|
||||
'data' => $this->data,
|
||||
'routes' => $this->routes,
|
||||
'debug' => $this->debug,
|
||||
'collections' => $this->collections,
|
||||
'models' => $this->models,
|
||||
'requestData' => $this->requestData,
|
||||
'requestMethod' => $this->requestMethod,
|
||||
...$props
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for an API collection
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If no collection for `$name` exists
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function collection(
|
||||
string $name,
|
||||
array|BaseCollection|null $collection = null
|
||||
): Collection {
|
||||
if (isset($this->collections[$name]) === false) {
|
||||
throw new NotFoundException(
|
||||
message: sprintf('The collection "%s" does not exist', $name)
|
||||
);
|
||||
}
|
||||
|
||||
return new Collection($this, $collection, $this->collections[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collections definition
|
||||
*/
|
||||
public function collections(): array
|
||||
{
|
||||
return $this->collections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the injected data array
|
||||
* or certain parts of it by key
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If no data for `$key` exists
|
||||
*/
|
||||
public function data(string|null $key = null, ...$args): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
if ($this->hasData($key) === false) {
|
||||
throw new NotFoundException(
|
||||
message: sprintf('Api data for "%s" does not exist', $key)
|
||||
);
|
||||
}
|
||||
|
||||
// lazy-load data wrapped in Closures
|
||||
if ($this->data[$key] instanceof Closure) {
|
||||
return $this->data[$key]->call($this, ...$args);
|
||||
}
|
||||
|
||||
return $this->data[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the debugging flag
|
||||
*/
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->debug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if injected data exists for the given key
|
||||
*/
|
||||
public function hasData(string $key): bool
|
||||
{
|
||||
return isset($this->data[$key]) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches an object with an array item
|
||||
* based on the `type` field
|
||||
*
|
||||
* @param array models or collections
|
||||
* @return string|null key of match
|
||||
*/
|
||||
protected function match(
|
||||
array $array,
|
||||
$object = null
|
||||
): string|null {
|
||||
foreach ($array as $definition => $model) {
|
||||
if ($object instanceof $model['type']) {
|
||||
return $definition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an API model instance by name
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If no model for `$name` exists
|
||||
*/
|
||||
public function model(
|
||||
string|null $name = null,
|
||||
$object = null
|
||||
): Model {
|
||||
// Try to auto-match object with API models
|
||||
$name ??= $this->match($this->models, $object);
|
||||
|
||||
if (isset($this->models[$name]) === false) {
|
||||
throw new NotFoundException(
|
||||
message: sprintf('The model "%s" does not exist', $name ?? 'NULL')
|
||||
);
|
||||
}
|
||||
|
||||
return new Model($this, $object, $this->models[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all model definitions
|
||||
*/
|
||||
public function models(): array
|
||||
{
|
||||
return $this->models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter for request data
|
||||
* Can either get all the data
|
||||
* or certain parts of it.
|
||||
*/
|
||||
public function requestData(
|
||||
string|null $type = null,
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
if ($type === null) {
|
||||
return $this->requestData;
|
||||
}
|
||||
|
||||
if ($key === null) {
|
||||
return $this->requestData[$type] ?? [];
|
||||
}
|
||||
|
||||
$data = array_change_key_case($this->requestData($type));
|
||||
$key = strtolower($key);
|
||||
|
||||
return $data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request body if available
|
||||
*/
|
||||
public function requestBody(
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
return $this->requestData('body', $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the files from the request if available
|
||||
*/
|
||||
public function requestFiles(
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
return $this->requestData('files', $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all headers from the request if available
|
||||
*/
|
||||
public function requestHeaders(
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
return $this->requestData('headers', $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request method
|
||||
*/
|
||||
public function requestMethod(): string|null
|
||||
{
|
||||
return $this->requestMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request query if available
|
||||
*/
|
||||
public function requestQuery(
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
return $this->requestData('query', $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a Kirby object into an
|
||||
* API model or collection representation
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If `$object` cannot be resolved
|
||||
*/
|
||||
public function resolve($object): Model|Collection
|
||||
{
|
||||
if (
|
||||
$object instanceof Model ||
|
||||
$object instanceof Collection
|
||||
) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
if ($model = $this->match($this->models, $object)) {
|
||||
return $this->model($model, $object);
|
||||
}
|
||||
|
||||
if ($collection = $this->match($this->collections, $object)) {
|
||||
return $this->collection($collection, $object);
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
message: sprintf('The object "%s" cannot be resolved', $object::class)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all defined routes
|
||||
*/
|
||||
public function routes(): array
|
||||
{
|
||||
return $this->routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the API call
|
||||
*/
|
||||
public function render(
|
||||
string $path,
|
||||
string $method = 'GET',
|
||||
array $requestData = []
|
||||
): mixed {
|
||||
try {
|
||||
$result = $this->call($path, $method, $requestData);
|
||||
} catch (Throwable $e) {
|
||||
$result = $this->responseForException($e);
|
||||
}
|
||||
|
||||
$result = match ($result) {
|
||||
null => $this->responseFor404(),
|
||||
false => $this->responseFor400(),
|
||||
true => $this->responseFor200(),
|
||||
default => $result
|
||||
};
|
||||
|
||||
if (is_array($result) === false) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// pretty print json data
|
||||
$pretty = (bool)($requestData['query']['pretty'] ?? false) === true;
|
||||
|
||||
if (($result['status'] ?? 'ok') === 'error') {
|
||||
$code = $result['code'] ?? 400;
|
||||
|
||||
// sanitize the error code
|
||||
if ($code < 400 || $code > 599) {
|
||||
$code = 500;
|
||||
}
|
||||
|
||||
return Response::json($result, $code, $pretty);
|
||||
}
|
||||
|
||||
return Response::json($result, 200, $pretty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 200 - ok
|
||||
* response array.
|
||||
*/
|
||||
public function responseFor200(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => 'ok',
|
||||
'code' => 200
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 400 - bad request
|
||||
* response array.
|
||||
*/
|
||||
public function responseFor400(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => 'bad request',
|
||||
'code' => 400,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 404 - not found
|
||||
* response array.
|
||||
*/
|
||||
public function responseFor404(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => 'not found',
|
||||
'code' => 404,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the response array for
|
||||
* an exception. Kirby exceptions will
|
||||
* have more information
|
||||
*/
|
||||
public function responseForException(Throwable $e): array
|
||||
{
|
||||
if (isset($this->kirby) === true) {
|
||||
$docRoot = $this->kirby->environment()->get('DOCUMENT_ROOT');
|
||||
} else {
|
||||
$docRoot = $_SERVER['DOCUMENT_ROOT'] ?? null;
|
||||
}
|
||||
|
||||
// prepare the result array for all exception types
|
||||
$result = [
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
'code' => empty($e->getCode()) === true ? 500 : $e->getCode(),
|
||||
'exception' => $e::class,
|
||||
'key' => null,
|
||||
'file' => F::relativepath($e->getFile(), $docRoot),
|
||||
'line' => $e->getLine(),
|
||||
'details' => [],
|
||||
'route' => $this->route?->pattern()
|
||||
];
|
||||
|
||||
// extend the information for Kirby Exceptions
|
||||
if ($e instanceof ExceptionException) {
|
||||
$result['key'] = $e->getKey();
|
||||
$result['details'] = $e->getDetails();
|
||||
$result['code'] = $e->getHttpCode();
|
||||
}
|
||||
|
||||
// remove critical info from the result set if
|
||||
// debug mode is switched off
|
||||
if ($this->debug !== true) {
|
||||
unset(
|
||||
$result['file'],
|
||||
$result['exception'],
|
||||
$result['line'],
|
||||
$result['route']
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the request data
|
||||
* @return $this
|
||||
*/
|
||||
protected function setRequestData(
|
||||
array|null $requestData = []
|
||||
): static {
|
||||
$this->requestData = [
|
||||
'query' => [],
|
||||
'body' => [],
|
||||
'files' => [],
|
||||
...$requestData ?? []
|
||||
];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the request method
|
||||
* @return $this
|
||||
*/
|
||||
protected function setRequestMethod(
|
||||
string|null $requestMethod = null
|
||||
): static {
|
||||
$this->requestMethod = $requestMethod ?? 'GET';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload helper method
|
||||
*
|
||||
* move_uploaded_file() not working with unit test
|
||||
* Added debug parameter for testing purposes as we did in the Email class
|
||||
*
|
||||
* @throws \Exception If request has no files or there was an error with the upload
|
||||
*/
|
||||
public function upload(
|
||||
Closure $callback,
|
||||
bool $single = false,
|
||||
bool $debug = false
|
||||
): array {
|
||||
return (new Upload($this, $single, $debug))->process($callback);
|
||||
}
|
||||
}
|
||||
153
kirby/src/Api/Collection.php
Normal file
153
kirby/src/Api/Collection.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Kirby\Toolkit\Collection as BaseCollection;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Collection class is a wrapper
|
||||
* around our Kirby Collections and handles
|
||||
* stuff like pagination and proper JSON output
|
||||
* for collections in REST calls.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Collection
|
||||
{
|
||||
protected string|null $model;
|
||||
protected array|null $select = null;
|
||||
protected string|null $view;
|
||||
|
||||
/**
|
||||
* Collection constructor
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct(
|
||||
protected Api $api,
|
||||
protected BaseCollection|array|null $data,
|
||||
array $schema
|
||||
) {
|
||||
$this->model = $schema['model'] ?? null;
|
||||
$this->view = $schema['view'] ?? null;
|
||||
|
||||
if ($data === null) {
|
||||
if (($schema['default'] ?? null) instanceof Closure === false) {
|
||||
throw new Exception(message: 'Missing collection data');
|
||||
}
|
||||
|
||||
$this->data = $schema['default']->call($this->api);
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['type']) === true &&
|
||||
$this->data instanceof $schema['type'] === false
|
||||
) {
|
||||
throw new Exception(message: 'Invalid collection type');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function select($keys = null): static
|
||||
{
|
||||
if ($keys === false) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (is_string($keys)) {
|
||||
$keys = Str::split($keys);
|
||||
}
|
||||
|
||||
if ($keys !== null && is_array($keys) === false) {
|
||||
throw new Exception(message: 'Invalid select keys');
|
||||
}
|
||||
|
||||
$this->select = $keys;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($this->data as $item) {
|
||||
$model = $this->api->model($this->model, $item);
|
||||
|
||||
if ($this->view !== null) {
|
||||
$model = $model->view($this->view);
|
||||
}
|
||||
|
||||
if ($this->select !== null) {
|
||||
$model = $model->select($this->select);
|
||||
}
|
||||
|
||||
$result[] = $model->toArray();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function toResponse(): array
|
||||
{
|
||||
if ($query = $this->api->requestQuery('query')) {
|
||||
$this->data = $this->data->query($query);
|
||||
}
|
||||
|
||||
if (!$this->data->pagination()) {
|
||||
$this->data = $this->data->paginate([
|
||||
'page' => $this->api->requestQuery('page', 1),
|
||||
'limit' => $this->api->requestQuery('limit', 100)
|
||||
]);
|
||||
}
|
||||
|
||||
$pagination = $this->data->pagination();
|
||||
|
||||
if ($select = $this->api->requestQuery('select')) {
|
||||
$this->select($select);
|
||||
}
|
||||
|
||||
if ($view = $this->api->requestQuery('view')) {
|
||||
$this->view($view);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'data' => $this->toArray(),
|
||||
'pagination' => [
|
||||
'page' => $pagination->page(),
|
||||
'total' => $pagination->total(),
|
||||
'offset' => $pagination->offset(),
|
||||
'limit' => $pagination->limit(),
|
||||
],
|
||||
'status' => 'ok',
|
||||
'type' => 'collection'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function view(string $view): static
|
||||
{
|
||||
$this->view = $view;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
137
kirby/src/Api/Controller/Changes.php
Normal file
137
kirby/src/Api/Controller/Changes.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api\Controller;
|
||||
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Content\Lock;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Form\Fields;
|
||||
use Kirby\Form\Form;
|
||||
|
||||
/**
|
||||
* The Changes controller takes care of the request logic
|
||||
* to save, discard and publish changes.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Changes
|
||||
{
|
||||
/**
|
||||
* Cleans up legacy lock files. The `discard`, `publish` and `save` actions
|
||||
* are perfect for this cleanup job. They will be stopped early if
|
||||
* the lock is still active and otherwise, we can use them to clean
|
||||
* up outdated .lock files to keep the content folders clean. This
|
||||
* can be removed as soon as old .lock files should no longer be around.
|
||||
*
|
||||
* @todo Remove in 6.0.0
|
||||
*/
|
||||
protected static function cleanup(ModelWithContent $model): void
|
||||
{
|
||||
F::remove(Lock::legacyFile($model));
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards unsaved changes by deleting the changes version
|
||||
*/
|
||||
public static function discard(ModelWithContent $model): array
|
||||
{
|
||||
$model->version('changes')->delete('current');
|
||||
|
||||
// Removes the old .lock file when it is no longer needed
|
||||
// @todo Remove in 6.0.0
|
||||
static::cleanup($model);
|
||||
|
||||
return [
|
||||
'status' => 'ok'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the lastest state of changes first and then publishes them
|
||||
*/
|
||||
public static function publish(ModelWithContent $model, array $input): array
|
||||
{
|
||||
// save the given changes first
|
||||
static::save(
|
||||
model: $model,
|
||||
input: $input
|
||||
);
|
||||
|
||||
// Removes the old .lock file when it is no longer needed
|
||||
// @todo Remove in 6.0.0
|
||||
static::cleanup($model);
|
||||
|
||||
// get the changes version
|
||||
$changes = $model->version('changes');
|
||||
|
||||
// if the changes version does not exist, we need to return early
|
||||
if ($changes->exists('current') === false) {
|
||||
return [
|
||||
'status' => 'ok',
|
||||
];
|
||||
}
|
||||
|
||||
// publish the changes
|
||||
$changes->publish(
|
||||
language: 'current'
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => 'ok'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves form input in a new or existing `changes` version
|
||||
*/
|
||||
public static function save(ModelWithContent $model, array $input): array
|
||||
{
|
||||
// Removes the old .lock file when it is no longer needed
|
||||
// @todo Remove in 6.0.0
|
||||
static::cleanup($model);
|
||||
|
||||
// get the current language
|
||||
$language = Language::ensure('current');
|
||||
|
||||
// create the fields instance for the model
|
||||
$fields = Fields::for($model, $language);
|
||||
|
||||
// get the changes and latest version for the model
|
||||
$changes = $model->version('changes');
|
||||
$latest = $model->version('latest');
|
||||
|
||||
// get the source version for the existing content
|
||||
$source = $changes->exists($language) === true ? $changes : $latest;
|
||||
$content = $source->content($language)->toArray();
|
||||
|
||||
// fill in the form values and pass through any values that are not
|
||||
// defined as fields, such as uuid, title or similar.
|
||||
$fields->fill(input: $content);
|
||||
|
||||
// submit the new values from the request input
|
||||
$fields->submit(input: $input);
|
||||
|
||||
// save the changes
|
||||
$changes->save(
|
||||
fields: $fields->toStoredValues(),
|
||||
language: $language
|
||||
);
|
||||
|
||||
// if the changes are identical to the latest version,
|
||||
// we can delete the changes version already at this point
|
||||
if ($changes->isIdentical(version: $latest, language: $language)) {
|
||||
$changes->delete(
|
||||
language: $language
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok'
|
||||
];
|
||||
}
|
||||
}
|
||||
227
kirby/src/Api/Model.php
Normal file
227
kirby/src/Api/Model.php
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The API Model class can be wrapped around any
|
||||
* kind of object. Each model defines a set of properties that
|
||||
* are available in REST calls. Those properties are defined as
|
||||
* simple Closures which are resolved on demand. This is inspired
|
||||
* by GraphQLs architecture and makes it possible to load
|
||||
* only the model data that is needed for the current API call.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Model
|
||||
{
|
||||
protected array $fields;
|
||||
protected array|null $select;
|
||||
protected array $views;
|
||||
|
||||
/**
|
||||
* Model constructor
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct(
|
||||
protected Api $api,
|
||||
protected object|array|string|null $data,
|
||||
array $schema
|
||||
) {
|
||||
$this->fields = $schema['fields'] ?? [];
|
||||
$this->select = $schema['select'] ?? null;
|
||||
$this->views = $schema['views'] ?? [];
|
||||
|
||||
if (
|
||||
$this->select === null &&
|
||||
array_key_exists('default', $this->views)
|
||||
) {
|
||||
$this->view('default');
|
||||
}
|
||||
|
||||
if ($data === null) {
|
||||
if (($schema['default'] ?? null) instanceof Closure === false) {
|
||||
throw new Exception(message: 'Missing model data');
|
||||
}
|
||||
|
||||
$this->data = $schema['default']->call($this->api);
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['type']) === true &&
|
||||
$this->data instanceof $schema['type'] === false
|
||||
) {
|
||||
$class = match ($this->data) {
|
||||
null => 'null',
|
||||
default => $this->data::class,
|
||||
};
|
||||
throw new Exception(sprintf('Invalid model type "%s" expected: "%s"', $class, $schema['type']));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function select($keys = null): static
|
||||
{
|
||||
if ($keys === false) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (is_string($keys)) {
|
||||
$keys = Str::split($keys);
|
||||
}
|
||||
|
||||
if ($keys !== null && is_array($keys) === false) {
|
||||
throw new Exception(message: 'Invalid select keys');
|
||||
}
|
||||
|
||||
$this->select = $keys;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function selection(): array
|
||||
{
|
||||
$select = $this->select;
|
||||
$select ??= array_keys($this->fields);
|
||||
$selection = [];
|
||||
|
||||
foreach ($select as $key => $value) {
|
||||
if (is_int($key) === true) {
|
||||
$selection[$value] = [
|
||||
'view' => null,
|
||||
'select' => null
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) === true) {
|
||||
if ($value === 'any') {
|
||||
throw new Exception(message: 'Invalid sub view: "any"');
|
||||
}
|
||||
|
||||
$selection[$key] = [
|
||||
'view' => $value,
|
||||
'select' => null
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value) === true) {
|
||||
$selection[$key] = [
|
||||
'view' => null,
|
||||
'select' => $value
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$select = $this->selection();
|
||||
$result = [];
|
||||
|
||||
foreach ($this->fields as $key => $resolver) {
|
||||
if (
|
||||
array_key_exists($key, $select) === false ||
|
||||
$resolver instanceof Closure === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $resolver->call($this->api, $this->data);
|
||||
|
||||
if (is_object($value)) {
|
||||
$value = $this->api->resolve($value);
|
||||
}
|
||||
|
||||
if (
|
||||
$value instanceof Collection ||
|
||||
$value instanceof self
|
||||
) {
|
||||
$selection = $select[$key];
|
||||
|
||||
if ($subview = $selection['view']) {
|
||||
$value->view($subview);
|
||||
}
|
||||
|
||||
if ($subselect = $selection['select']) {
|
||||
$value->select($subselect);
|
||||
}
|
||||
|
||||
$value = $value->toArray();
|
||||
}
|
||||
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
ksort($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function toResponse(): array
|
||||
{
|
||||
$model = $this;
|
||||
|
||||
if ($select = $this->api->requestQuery('select')) {
|
||||
$model = $model->select($select);
|
||||
}
|
||||
|
||||
if ($view = $this->api->requestQuery('view')) {
|
||||
$model = $model->view($view);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'data' => $model->toArray(),
|
||||
'status' => 'ok',
|
||||
'type' => 'model'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function view(string $name): static
|
||||
{
|
||||
if ($name === 'any') {
|
||||
return $this->select(null);
|
||||
}
|
||||
|
||||
if (isset($this->views[$name]) === false) {
|
||||
$name = 'default';
|
||||
|
||||
// try to fall back to the default view at least
|
||||
if (isset($this->views[$name]) === false) {
|
||||
throw new Exception(sprintf('The view "%s" does not exist', $name));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->select($this->views[$name]);
|
||||
}
|
||||
}
|
||||
436
kirby/src/Api/Upload.php
Normal file
436
kirby/src/Api/Upload.php
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Cms\FileRules;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Exception\DuplicateException;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Upload class handles file uploads in the
|
||||
* context of the API. It adds support for chunked
|
||||
* uploads.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
readonly class Upload
|
||||
{
|
||||
public function __construct(
|
||||
protected Api $api,
|
||||
protected bool $single = true,
|
||||
protected bool $debug = false
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a clean chunk ID by stripping forbidden characters
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
|
||||
*/
|
||||
public static function chunkId(string $id): string
|
||||
{
|
||||
$id = Str::slug($id, '', 'a-z0-9');
|
||||
|
||||
if (strlen($id) < 3) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Chunk ID must at least be 3 characters long'
|
||||
);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ideal size for a file chunk
|
||||
*/
|
||||
public static function chunkSize(): int
|
||||
{
|
||||
$max = [
|
||||
Str::toBytes(ini_get('upload_max_filesize')),
|
||||
Str::toBytes(ini_get('post_max_size'))
|
||||
];
|
||||
|
||||
// consider cloudflare proxy limit, if detected
|
||||
if (isset($_SERVER['HTTP_CF_CONNECTING_IP']) === true) {
|
||||
$max[] = Str::toBytes('100M');
|
||||
}
|
||||
|
||||
// to be sure, only use 95% of the max possible upload size
|
||||
return (int)floor(min($max) * 0.95);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up tmp directory of stale files
|
||||
*/
|
||||
public static function cleanTmpDir(): void
|
||||
{
|
||||
foreach (Dir::files($dir = static::tmpDir(), [], true) as $file) {
|
||||
// remove any file that hasn't been altered
|
||||
// in the last 24 hours
|
||||
if (F::modified($file) < time() - 86400) {
|
||||
F::remove($file);
|
||||
}
|
||||
}
|
||||
|
||||
// remove tmp directory if completely empty
|
||||
if (Dir::isEmpty($dir) === true) {
|
||||
Dir::remove($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an exception with the appropriate translated error message
|
||||
*
|
||||
* @throws \Exception Any upload error
|
||||
*/
|
||||
public static function error(int $error): void
|
||||
{
|
||||
// get error messages from translation
|
||||
$message = [
|
||||
UPLOAD_ERR_INI_SIZE => I18n::translate('upload.error.iniSize'),
|
||||
UPLOAD_ERR_FORM_SIZE => I18n::translate('upload.error.formSize'),
|
||||
UPLOAD_ERR_PARTIAL => I18n::translate('upload.error.partial'),
|
||||
UPLOAD_ERR_NO_FILE => I18n::translate('upload.error.noFile'),
|
||||
UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
|
||||
UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
|
||||
UPLOAD_ERR_EXTENSION => I18n::translate('upload.error.extension')
|
||||
];
|
||||
|
||||
throw new Exception(
|
||||
message: $message[$error] ?? I18n::translate('upload.error.default', 'The file could not be uploaded')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the filename and extension
|
||||
* based on the detected mime type
|
||||
*/
|
||||
public static function filename(array $upload): string
|
||||
{
|
||||
// get the extension of the uploaded file
|
||||
$extension = F::extension($upload['name']);
|
||||
|
||||
// try to detect the correct mime and add the extension
|
||||
// accordingly. This will avoid .tmp filenames
|
||||
if (
|
||||
empty($extension) === true ||
|
||||
in_array($extension, ['tmp', 'temp'], true) === true
|
||||
) {
|
||||
$mime = F::mime($upload['tmp_name']);
|
||||
$extension = F::mimeToExtension($mime);
|
||||
$filename = F::name($upload['name']) . '.' . $extension;
|
||||
return $filename;
|
||||
}
|
||||
|
||||
return basename($upload['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the files and call closure for each file
|
||||
*
|
||||
* @throws \Exception Any upload error
|
||||
*/
|
||||
public function process(Closure $callback): array
|
||||
{
|
||||
$files = $this->api->requestFiles();
|
||||
$uploads = [];
|
||||
$errors = [];
|
||||
|
||||
static::validateFiles($files);
|
||||
|
||||
foreach ($files as $upload) {
|
||||
if (
|
||||
isset($upload['tmp_name']) === false &&
|
||||
is_array($upload) === true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($upload['error'] !== 0) {
|
||||
static::error($upload['error']);
|
||||
}
|
||||
|
||||
$filename = static::filename($upload);
|
||||
$source = $this->source($upload['tmp_name'], $filename);
|
||||
|
||||
// if the file is uploaded in chunks…
|
||||
if ($this->api->requestHeaders('Upload-Length')) {
|
||||
$source = $this->processChunk($source, $filename);
|
||||
}
|
||||
|
||||
// apply callback only to complete uploads
|
||||
// (incomplete chunk request will return empty $source)
|
||||
$data = match ($source) {
|
||||
null => null,
|
||||
default => $callback($source, $filename)
|
||||
};
|
||||
|
||||
$uploads[$upload['name']] = match (true) {
|
||||
is_object($data) => $this->api->resolve($data)->toArray(),
|
||||
default => $data
|
||||
};
|
||||
} catch (Exception $e) {
|
||||
$errors[$upload['name']] = $e->getMessage();
|
||||
|
||||
// clean up file from system tmp directory
|
||||
F::unlink($upload['tmp_name']);
|
||||
}
|
||||
|
||||
if ($this->single === true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return static::response($uploads, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle chunked uploads by merging all chunks
|
||||
* in the tmp directory and only returning the new
|
||||
* $source path to the tmp file once complete
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
|
||||
* @throws \Kirby\Exception\Exception Chunk offset does not match existing tmp file
|
||||
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
|
||||
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
|
||||
*/
|
||||
public function processChunk(
|
||||
string $source,
|
||||
string $filename
|
||||
): string|null {
|
||||
// ensure the tmp upload directory exists
|
||||
Dir::make($dir = static::tmpDir());
|
||||
|
||||
// create path for file in tmp upload directory;
|
||||
// prefix with id while file isn't completely uploaded yet
|
||||
$id = $this->api->requestHeaders('Upload-Id', '');
|
||||
$id = static::chunkId($id);
|
||||
$total = (int)$this->api->requestHeaders('Upload-Length');
|
||||
$filename = basename($filename);
|
||||
$tmpRoot = $dir . '/' . $id . '-' . $filename;
|
||||
|
||||
// validate various aspects of the request
|
||||
// to ensure the chunk isn't trying to do malicious actions
|
||||
static::validateChunk(
|
||||
source: $source,
|
||||
tmp: $tmpRoot,
|
||||
total: $total,
|
||||
offset: $this->api->requestHeaders('Upload-Offset'),
|
||||
template: $this->api->requestBody('template'),
|
||||
);
|
||||
|
||||
// stream chunk content and append it to partial file
|
||||
stream_copy_to_stream(
|
||||
fopen($source, 'r'),
|
||||
fopen($tmpRoot, 'a')
|
||||
);
|
||||
|
||||
// clear file stat cache so the following call to `F::size`
|
||||
// really returns the updated file size
|
||||
clearstatcache();
|
||||
|
||||
// if file isn't complete yet, return early
|
||||
if (F::size($tmpRoot) < $total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// remove id from partial filename now the file is complete,
|
||||
// so we can pass the path from the tmp upload directory
|
||||
// as new source path for the file back to the API upload method
|
||||
rename(
|
||||
$tmpRoot,
|
||||
$source = $dir . '/' . $filename
|
||||
);
|
||||
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert uploads and errors in response array for API response
|
||||
*/
|
||||
public static function response(
|
||||
array $uploads,
|
||||
array $errors
|
||||
): array {
|
||||
if (count($uploads) + count($errors) <= 1) {
|
||||
if (count($errors) > 0) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => current($errors)
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'data' => $uploads ? current($uploads) : null
|
||||
];
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'errors' => $errors
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'data' => $uploads
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the tmp file to a location including the extension,
|
||||
* for better mime detection and return updated source path
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function source(string $source, string $filename): string
|
||||
{
|
||||
if ($this->debug === true) {
|
||||
return $source;
|
||||
}
|
||||
|
||||
$target = dirname($source) . '/' . uniqid() . '.' . $filename;
|
||||
|
||||
if (move_uploaded_file($source, $target)) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
message: I18n::translate('upload.error.cantMove')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns root of directory used for
|
||||
* temporarily storing (incomplete) uploads
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
protected static function tmpDir(): string
|
||||
{
|
||||
return App::instance()->root('cache') . '/.uploads';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the sent chunk is valid
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
|
||||
* @throws \Kirby\Exception\InvalidArgumentException Chunk offset does not match existing tmp file
|
||||
* @throws \Kirby\Exception\InvalidArgumentException The maximum file size for this blueprint was exceeded
|
||||
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
|
||||
*/
|
||||
protected static function validateChunk(
|
||||
string $source,
|
||||
string $tmp,
|
||||
int $total,
|
||||
int $offset,
|
||||
string|null $template = null
|
||||
): void {
|
||||
$file = new File([
|
||||
'parent' => new Page(['slug' => 'tmp']),
|
||||
'filename' => $filename = basename($tmp),
|
||||
'template' => $template
|
||||
]);
|
||||
|
||||
// if the blueprint `maxsize` option is set,
|
||||
// ensure that the total size communicated in the header
|
||||
// as well as the current tmp size after adding this chunk
|
||||
// do not exceed the max limit
|
||||
if (
|
||||
($max = $file->blueprint()->accept()['maxsize'] ?? null) &&
|
||||
(
|
||||
$total > $max ||
|
||||
(F::size($source) + F::size($tmp)) > $max
|
||||
)
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.maxsize'
|
||||
);
|
||||
}
|
||||
|
||||
// validate the first chunk
|
||||
if ($offset === 0) {
|
||||
// sent chunk is expected to be the first part,
|
||||
// but tmp file already exists
|
||||
if (F::exists($tmp) === true) {
|
||||
throw new DuplicateException(
|
||||
message: 'A tmp file upload with the same filename and upload id already exists: ' . $filename
|
||||
);
|
||||
}
|
||||
|
||||
// validate file (extension, name) for first chunk;
|
||||
// will also be validate again by `$model->createFile()`
|
||||
// when completely uploaded
|
||||
FileRules::validFile($file, false);
|
||||
|
||||
// first chunk is valid
|
||||
return;
|
||||
}
|
||||
|
||||
// validate subsequent chunks:
|
||||
// no tmp in place
|
||||
if (F::exists($tmp) === false) {
|
||||
throw new NotFoundException(
|
||||
message: 'Chunk offset ' . $offset . ' for non-existing tmp file: ' . $filename
|
||||
);
|
||||
}
|
||||
|
||||
// sent chunk's offset is not the continuation of the tmp file
|
||||
if ($offset !== F::size($tmp)) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Chunk offset ' . $offset . ' does not match the existing tmp upload file size of ' . F::size($tmp)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the files array for upload
|
||||
*
|
||||
* @throws \Exception No files were uploaded
|
||||
*/
|
||||
protected static function validateFiles(array $files): void
|
||||
{
|
||||
if ($files === []) {
|
||||
$postMaxSize = Str::toBytes(ini_get('post_max_size'));
|
||||
$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
if ($postMaxSize < $uploadMaxFileSize) {
|
||||
throw new Exception(
|
||||
message:
|
||||
I18n::translate(
|
||||
'upload.error.iniPostSize',
|
||||
'The uploaded file exceeds the post_max_size directive in php.ini'
|
||||
)
|
||||
);
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
throw new Exception(
|
||||
message:
|
||||
I18n::translate(
|
||||
'upload.error.noFiles',
|
||||
'No files were uploaded'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue