feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,643 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Api;
use Grav\Common\Page\Media;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Controllers\HandlesMediaUploads;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\FlexObjects\Flex;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class FlexApiController extends AbstractApiController
{
use HandlesMediaUploads;
/**
* GET /flex-objects/config
*
* Returns UI-relevant plugin configuration for admin-next. Never returns
* secrets or backend-only data. The flex directories list is exposed
* separately via GET /flex-objects so callers that just need config stay
* lightweight.
*/
public function config(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$cfg = $this->config->get('plugins.flex-objects', []);
return ApiResponse::create([
'enabled' => (bool) ($cfg['enabled'] ?? true),
'built_in_css' => (bool) ($cfg['built_in_css'] ?? true),
'security' => [
'restrict_page_frontmatter' => (bool) ($cfg['security']['restrict_page_frontmatter'] ?? true),
],
'admin_list' => [
'per_page' => (int) ($cfg['admin_list']['per_page'] ?? 15),
'order' => [
'by' => (string) ($cfg['admin_list']['order']['by'] ?? 'updated_timestamp'),
'dir' => (string) ($cfg['admin_list']['order']['dir'] ?? 'desc'),
],
],
]);
}
/**
* GET /flex-objects — List all enabled flex directories with their admin config.
*/
public function directories(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$flex = $this->getFlex();
$user = $this->getUser($request);
$result = [];
// Skip built-in types that already have dedicated admin-next UI
$builtIn = ['pages', 'user-accounts', 'user-groups'];
foreach ($flex->getDirectories() as $directory) {
if (!$directory->isEnabled()) {
continue;
}
if (in_array($directory->getFlexType(), $builtIn, true)) {
continue;
}
$config = $directory->getConfig('admin');
if (empty($config) || !empty($config['disabled'])) {
continue;
}
// Skip directories the user cannot list
if (!$this->isSuperAdmin($user) && !$directory->isAuthorized('list', 'admin', $user)) {
continue;
}
$menu = $config['menu']['list'] ?? [];
// Resolve form field types for list columns so frontend can render properly
$listFields = $config['list']['fields'] ?? [];
$fieldTypes = [];
try {
$blueprint = $directory->getBlueprint();
$formFields = $blueprint->fields();
foreach (array_keys($listFields) as $fieldName) {
$fieldTypes[$fieldName] = $formFields[$fieldName]['type'] ?? 'text';
}
} catch (\Exception $e) {
// Non-critical
}
$result[] = [
'type' => $directory->getFlexType(),
'title' => $menu['title'] ?? $directory->getTitle(),
'description' => $directory->getDescription() ?? '',
'icon' => $menu['icon'] ?? 'fa-file',
'list' => $config['list'] ?? [],
'edit' => $config['edit'] ?? [],
'search' => $directory->getConfig('data.search') ?? [],
'field_types' => $fieldTypes,
'export' => $config['export'] ?? [],
];
}
return ApiResponse::create($result);
}
/**
* GET /flex-objects/blueprints — List every available flex directory blueprint.
*
* Powers the `directories` field on the flex-objects plugin settings page:
* one toggle per available blueprint, value is the array of enabled
* blueprint URLs. Includes hidden + currently-disabled blueprints because
* they're the things the admin is choosing to enable. The legacy URL
* (pre-rc.4 alias) is included so the field can match saved values that
* still reference the old form.
*/
public function blueprints(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$flex = $this->getFlex();
$newToOld = Flex::getLegacyBlueprintMap(false); // [newUrl => oldUrl]
$items = [];
foreach ($flex->getBlueprints() as $directory) {
$url = $directory->getBlueprintFile();
$items[] = [
'url' => $url,
'legacy_url' => $newToOld[$url] ?? null,
'type' => $directory->getFlexType(),
'title' => $directory->getTitle(),
'description' => $directory->getDescription(),
];
}
return ApiResponse::create($items);
}
/**
* GET /flex-objects/{type} — List objects with pagination, search, sort.
*/
public function index(ServerRequestInterface $request): ResponseInterface
{
$type = $this->getRouteParam($request, 'type');
$directory = $this->resolveDirectory($type);
$this->requireFlexPermission($request, $directory, 'list');
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$search = $query['search'] ?? null;
$sortField = $query['sort'] ?? null;
$sortOrder = strtolower($query['order'] ?? 'asc');
if (!in_array($sortOrder, ['asc', 'desc'], true)) {
$sortOrder = 'asc';
}
$collection = $directory->getCollection();
// Apply search
if ($search && $search !== '') {
$collection = $collection->search($search);
}
// Apply sort
if ($sortField) {
$collection = $collection->sort([$sortField => $sortOrder]);
}
$total = $collection->count();
// Slice for pagination
$objects = $collection->slice($pagination['offset'], $pagination['limit']);
// Get list field names from config
$listFields = array_keys($directory->getConfig('admin.list.fields') ?? []);
$data = [];
foreach ($objects as $object) {
$data[] = $this->serializeForList($object, $listFields);
}
return ApiResponse::paginated(
data: $data,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/flex-objects/' . $type,
);
}
/**
* GET /flex-objects/{type}/{key} — Get a single object.
*/
public function show(ServerRequestInterface $request): ResponseInterface
{
$type = $this->getRouteParam($request, 'type');
$directory = $this->resolveDirectory($type);
$this->requireFlexPermission($request, $directory, 'read');
$key = $this->getRouteParam($request, 'key');
$object = $directory->getObject($key);
if (!$object) {
throw new NotFoundException("Object '{$key}' not found in '{$type}'.");
}
return $this->respondWithEtag($this->serializeObject($object));
}
/**
* POST /flex-objects/{type} — Create a new object.
*/
public function create(ServerRequestInterface $request): ResponseInterface
{
$type = $this->getRouteParam($request, 'type');
$directory = $this->resolveDirectory($type);
$this->requireFlexPermission($request, $directory, 'create');
$body = $this->getRequestBody($request);
unset($body['__meta']);
try {
$object = $directory->createObject($body, '');
$object->save();
} catch (\Exception $e) {
throw new \Grav\Plugin\Api\Exceptions\ValidationException(
'Failed to create object: ' . $e->getMessage(),
);
}
$this->fireAdminEvent('onAdminAfterSave', ['object' => $object]);
$key = $object->getKey();
return ApiResponse::created(
data: $this->serializeObject($object),
location: $this->getApiBaseUrl() . '/flex-objects/' . $type . '/' . $key,
headers: $this->invalidationHeaders([
'flex-objects:' . $type . ':list',
]),
);
}
/**
* PATCH /flex-objects/{type}/{key} — Update an existing object.
*/
public function update(ServerRequestInterface $request): ResponseInterface
{
$type = $this->getRouteParam($request, 'type');
$directory = $this->resolveDirectory($type);
$this->requireFlexPermission($request, $directory, 'update');
$key = $this->getRouteParam($request, 'key');
$object = $directory->getObject($key);
if (!$object) {
throw new NotFoundException("Object '{$key}' not found in '{$type}'.");
}
// ETag validation
$currentEtag = $this->generateEtag($this->serializeObject($object));
$this->validateEtag($request, $currentEtag);
$body = $this->getRequestBody($request);
unset($body['__meta']);
try {
$object->update($body);
$object->save();
} catch (\Exception $e) {
throw new \Grav\Plugin\Api\Exceptions\ValidationException(
'Failed to update object: ' . $e->getMessage(),
);
}
$this->fireAdminEvent('onAdminAfterSave', ['object' => $object]);
return $this->respondWithEtag(
$this->serializeObject($object),
200,
['flex-objects:' . $type . ':list', 'flex-objects:' . $type . ':update:' . $key],
);
}
/**
* DELETE /flex-objects/{type}/{key} — Delete an object.
*/
public function delete(ServerRequestInterface $request): ResponseInterface
{
$type = $this->getRouteParam($request, 'type');
$directory = $this->resolveDirectory($type);
$this->requireFlexPermission($request, $directory, 'delete');
$key = $this->getRouteParam($request, 'key');
$object = $directory->getObject($key);
if (!$object) {
throw new NotFoundException("Object '{$key}' not found in '{$type}'.");
}
$object->delete();
$this->fireAdminEvent('onAdminAfterDelete', ['object' => $object]);
return ApiResponse::noContent(
$this->invalidationHeaders([
'flex-objects:' . $type . ':list',
'flex-objects:' . $type . ':delete:' . $key,
]),
);
}
/**
* GET /flex-objects/{type}/export — Export all objects as YAML.
*/
public function export(ServerRequestInterface $request): ResponseInterface
{
$type = $this->getRouteParam($request, 'type');
$directory = $this->resolveDirectory($type);
$this->requireFlexPermission($request, $directory, 'list');
$collection = $directory->getCollection();
$data = [];
foreach ($collection as $object) {
$data[$object->getKey()] = $object->jsonSerialize();
}
$yaml = \Grav\Common\Yaml::dump($data, 10, 2);
$filename = $type . '-' . date('Y-m-d') . '.yaml';
return new \Grav\Framework\Psr7\Response(
200,
[
'Content-Type' => 'application/x-yaml',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Cache-Control' => 'no-store, max-age=0',
],
$yaml,
);
}
/**
* GET /flex-objects/{type}/{key}/media — List media attached to an object.
*
* For folder-stored directories the media lives in the object's own
* storage folder (e.g. user-data://flex-objects/contacts/{id}), alongside
* the object's data file.
*/
public function mediaList(ServerRequestInterface $request): ResponseInterface
{
$type = $this->getRouteParam($request, 'type');
$directory = $this->resolveDirectory($type);
$this->requireFlexPermission($request, $directory, 'read');
$object = $this->resolveObject($directory, $request);
$folder = $this->resolveMediaFolder($object);
$media = new Media($folder);
$serialized = $this->getSerializer()->serializeCollection($media->all());
return ApiResponse::create($serialized);
}
/**
* POST /flex-objects/{type}/{key}/media — Upload file(s) to an object.
*/
public function mediaUpload(ServerRequestInterface $request): ResponseInterface
{
$type = $this->getRouteParam($request, 'type');
$directory = $this->resolveDirectory($type);
$this->requireFlexPermission($request, $directory, 'update');
$object = $this->resolveObject($directory, $request);
$key = $object->getKey();
$folder = $this->resolveMediaFolder($object);
if (!is_dir($folder) && !mkdir($folder, 0775, true) && !is_dir($folder)) {
throw new ValidationException('Unable to create media directory for this object.');
}
$uploadedFiles = $this->flattenUploadedFiles($request->getUploadedFiles());
if ($uploadedFiles === []) {
throw new ValidationException('No files were uploaded.');
}
// Honor per-field upload settings (random_name, accept, ...) forwarded
// by the file field; absent, this is an inert no-op.
$settings = $this->parseUploadFieldSettings($request);
$uploadedNames = [];
foreach ($uploadedFiles as $file) {
// Fire before event — plugins can throw to reject specific files
$this->fireEvent('onApiBeforeMediaUpload', [
'object' => $object,
'filename' => $file->getClientFilename(),
'type' => $file->getClientMediaType(),
'size' => $file->getSize(),
]);
$uploadedNames[] = $this->processUploadedFile($file, $folder, $settings);
}
// Fresh Media object to pick up the newly uploaded files
$media = new Media($folder);
$serialized = $this->getSerializer()->serializeCollection($media->all());
$this->fireAdminEvent('onAdminAfterAddMedia', ['object' => $object]);
$this->fireEvent('onApiMediaUploaded', [
'object' => $object,
'filenames' => $uploadedNames,
]);
return ApiResponse::created(
data: $serialized,
location: $this->getApiBaseUrl() . '/flex-objects/' . $type . '/' . $key . '/media',
headers: $this->invalidationHeaders([
'flex-objects:' . $type . ':media:' . $key,
'flex-objects:' . $type . ':update:' . $key,
]),
);
}
/**
* DELETE /flex-objects/{type}/{key}/media/{filename} — Delete a media file.
*/
public function mediaDelete(ServerRequestInterface $request): ResponseInterface
{
$type = $this->getRouteParam($request, 'type');
$directory = $this->resolveDirectory($type);
$this->requireFlexPermission($request, $directory, 'update');
$object = $this->resolveObject($directory, $request);
$key = $object->getKey();
$folder = $this->resolveMediaFolder($object);
$filename = $this->getSafeFilename($request);
$filePath = $folder . '/' . $filename;
if (!file_exists($filePath)) {
throw new NotFoundException("Media file '{$filename}' not found on this object.");
}
$this->fireEvent('onApiBeforeMediaDelete', ['object' => $object, 'filename' => $filename]);
unlink($filePath);
// Also remove any metadata sidecar (.meta.yaml) if it exists
$metaPath = $filePath . '.meta.yaml';
if (file_exists($metaPath)) {
unlink($metaPath);
}
$this->fireAdminEvent('onAdminAfterDelMedia', ['object' => $object, 'filename' => $filename]);
$this->fireEvent('onApiMediaDeleted', ['object' => $object, 'filename' => $filename]);
return ApiResponse::noContent(
$this->invalidationHeaders([
'flex-objects:' . $type . ':media:' . $key,
'flex-objects:' . $type . ':update:' . $key,
]),
);
}
// ─── Helpers ───────────────────────────────────────────────
/**
* Resolve the {key} route param to an existing object or throw a 404.
*/
private function resolveObject(FlexDirectory $directory, ServerRequestInterface $request): FlexObjectInterface
{
$key = $this->getRouteParam($request, 'key');
$object = $key !== null && $key !== '' ? $directory->getObject($key) : null;
if (!$object) {
$type = $directory->getFlexType();
throw new NotFoundException("Object '{$key}' not found in '{$type}'.");
}
return $object;
}
/**
* Resolve an object's media folder to an absolute, writable filesystem path.
*
* getMediaFolder() returns null for SimpleStorage directories (a single
* shared file, no per-object folder) and a GRAV_ROOT-relative or stream
* path for folder-stored ones. Normalize all cases to an absolute path.
*/
private function resolveMediaFolder(FlexObjectInterface $object): string
{
$folder = method_exists($object, 'getMediaFolder') ? $object->getMediaFolder() : null;
if (!$folder) {
throw new ValidationException(
'This directory does not support per-object media. '
. 'Object media requires folder-based storage.',
);
}
/** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */
$locator = $this->grav['locator'];
if ($locator->isStream($folder)) {
// Resolve to the absolute writable path, even if it doesn't exist yet
$resolved = $locator->findResource($folder, true, true);
if ($resolved) {
return $resolved;
}
}
// Already absolute? Use as-is. Otherwise treat as GRAV_ROOT-relative.
if (str_starts_with($folder, '/') || preg_match('#^[A-Za-z]:[\\\\/]#', $folder)) {
return $folder;
}
return rtrim(GRAV_ROOT, '/') . '/' . $folder;
}
private function getFlex(): Flex
{
return $this->grav['flex_objects'];
}
private function resolveDirectory(?string $type): FlexDirectory
{
if (!$type) {
throw new NotFoundException('Flex directory type is required.');
}
$flex = $this->getFlex();
$directory = $flex->getDirectory($type);
if (!$directory || !$directory->isEnabled()) {
throw new NotFoundException("Flex directory '{$type}' not found or not enabled.");
}
return $directory;
}
/**
* Check the directory-specific permission derived from the blueprint.
*
* Checks both api.* and admin.* prefixed permissions (OR logic) so users
* with either grant can access the flex directory via the API.
*/
private function requireFlexPermission(
ServerRequestInterface $request,
FlexDirectory $directory,
string $action,
): void {
$user = $this->getUser($request);
if ($this->isSuperAdmin($user)) {
return;
}
// Check API access
if (!$this->hasPermission($user, 'api.access')) {
throw new \Grav\Plugin\Api\Exceptions\ForbiddenException('API access is not enabled for this user.');
}
// Check directory-level permission from blueprint config.
// Blueprints may define both admin.* and api.* permissions — check all
// registered prefixes (OR: any matching permission grants access).
$permissions = $directory->getConfig('admin.permissions');
if ($permissions) {
foreach ($permissions as $prefix => $config) {
$permission = $prefix . '.' . $action;
if ($this->hasPermission($user, $permission)) {
return;
}
}
// None matched — report the first prefix for a clear error
$prefix = array_key_first($permissions);
throw new \Grav\Plugin\Api\Exceptions\ForbiddenException("Missing required permission: {$prefix}.{$action}");
}
}
private function serializeObject(FlexObjectInterface $object): array
{
$data = $object->jsonSerialize();
return array_merge(
['key' => $object->getKey(), '__meta' => $this->objectMeta($object)],
is_array($data) ? $data : [],
);
}
/**
* Read-only metadata for the admin "object info" panel: the identifier used
* in code snippets plus where the object lives on disk. Returned under the
* reserved `__meta` key so the admin can show it without it ever becoming
* part of the saved object data.
*
* @return array<string, string>
*/
private function objectMeta(FlexObjectInterface $object): array
{
$meta = [
'type' => $object->getFlexType(),
'key' => $object->getKey(),
'storageKey' => $object->getStorageKey(),
];
// Storage folder comes from the media trait, not the object interface,
// so guard it — some storages return null until the object is saved.
if (method_exists($object, 'getStorageFolder')) {
$folder = $object->getStorageFolder();
if ($folder) {
$meta['storagePath'] = $folder;
}
}
return $meta;
}
private function serializeForList(FlexObjectInterface $object, array $listFields): array
{
$data = ['key' => $object->getKey()];
if ($listFields) {
foreach ($listFields as $field) {
$data[$field] = $object->getProperty($field);
}
} else {
// No list config — return all data
$all = $object->jsonSerialize();
if (is_array($all)) {
$data = array_merge($data, $all);
}
}
return $data;
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Api;
use Grav\Plugin\Api\Controllers\BlueprintController;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\FlexObjects\Flex;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
class FlexBlueprintController extends BlueprintController
{
/**
* GET /blueprints/flex-objects/{type} — Serve the flex directory blueprint for form rendering.
*/
public function flexBlueprint(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$type = $this->getRouteParam($request, 'type');
/** @var Flex $flex */
$flex = $this->grav['flex_objects'];
$directory = $flex->getDirectory($type);
if (!$directory || !$directory->isEnabled()) {
throw new NotFoundException("Flex directory '{$type}' not found or not enabled.");
}
$blueprint = $directory->getBlueprint();
$data = $this->serializeBlueprint($blueprint, $type);
// Fire event to allow plugins to modify the serialized blueprint fields
$event = new Event([
'fields' => $data['fields'],
'template' => 'flex-objects/' . $type,
'user' => $this->getUser($request),
]);
$this->grav->fireEvent('onApiBlueprintResolved', $event);
$data['fields'] = $event['fields'];
return ApiResponse::create($data);
}
}
@@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Inflector;
use Grav\Common\Language\Language;
use Grav\Common\Session;
use Grav\Common\Uri;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Framework\Controller\Traits\ControllerResponseTrait;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Framework\Flex\FlexForm;
use Grav\Framework\Flex\FlexFormFlash;
use Grav\Framework\Flex\Interfaces\FlexFormInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Framework\Psr7\Response;
use Grav\Framework\RequestHandler\Exception\NotFoundException;
use Grav\Framework\RequestHandler\Exception\PageExpiredException;
use Grav\Framework\Route\Route;
use Grav\Plugin\FlexObjects\Flex;
use Grav\Plugin\Form\Forms;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\Session\Message;
use function in_array;
use function is_callable;
/**
* Class AbstractController
* @package Grav\Plugin\FlexObjects\Controllers
*/
abstract class AbstractController implements RequestHandlerInterface
{
use ControllerResponseTrait;
/** @var string */
protected $nonce_action = 'flex-object';
/** @var string */
protected $nonce_name = 'nonce';
/** @var ServerRequestInterface */
protected $request;
/** @var Grav */
protected $grav;
/** @var UserInterface|null */
protected $user;
/** @var string */
protected $type;
/** @var string */
protected $key;
/** @var FlexDirectory */
protected $directory;
/** @var FlexObjectInterface */
protected $object;
/**
* Handle request.
*
* Fires event: flex.[directory].[task|action].[command]
*
* @param ServerRequestInterface $request
* @return Response
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$attributes = $request->getAttributes();
$this->request = $request;
$this->grav = $attributes['grav'] ?? Grav::instance();
$this->type = $attributes['type'] ?? null;
$this->key = $attributes['key'] ?? null;
if ($this->type) {
$this->directory = $this->getFlex()->getDirectory($this->type);
$this->object = $attributes['object'] ?? null;
if (!$this->object && $this->key && $this->directory) {
$this->object = $this->directory->getObject($this->key) ?? $this->directory->createObject([], $this->key ?? '');
if (is_callable([$this->object, 'refresh'])) {
$this->object->refresh();
}
}
}
/** @var Route $route */
$route = $attributes['route'];
$post = $this->getPost();
if ($this->isFormSubmit()) {
$form = $this->getForm();
$this->nonce_name = $attributes['nonce_name'] ?? $form->getNonceName();
$this->nonce_action = $attributes['nonce_action'] ?? $form->getNonceAction();
}
try {
$task = $request->getAttribute('task') ?? $post['task'] ?? $route->getParam('task');
if ($task) {
if (empty($attributes['forwarded'])) {
$this->checkNonce($task);
}
$type = 'task';
$command = $task;
} else {
$type = 'action';
$command = $request->getAttribute('action') ?? $post['action'] ?? $route->getParam('action') ?? 'display';
}
$command = strtolower($command);
$event = new Event(
[
'controller' => $this,
'response' => null
]
);
$this->grav->fireEvent("flex.{$this->type}.{$type}.{$command}", $event);
$response = $event['response'];
if (!$response) {
/** @var Inflector $inflector */
$inflector = $this->grav['inflector'];
$method = $type . $inflector::camelize($command);
if ($method && method_exists($this, $method)) {
$response = $this->{$method}($request);
} else {
throw new NotFoundException($request);
}
}
} catch (\Exception $e) {
$response = $this->createErrorResponse($e);
}
if ($response instanceof Response) {
return $response;
}
return $this->createJsonResponse($response);
}
/**
* @return ServerRequestInterface
*/
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
/**
* @param string|null $name
* @param mixed $default
* @return mixed
*/
public function getPost(?string $name = null, $default = null)
{
$body = $this->request->getParsedBody();
if ($name) {
return $body[$name] ?? $default;
}
return $body;
}
/**
* @return bool
*/
public function isFormSubmit(): bool
{
return (bool)$this->getPost('__form-name__');
}
/**
* @param string|null $type
* @return FlexForm
*/
public function getForm(?string $type = null): FlexFormInterface
{
$object = $this->getObject();
if (!$object) {
throw new \RuntimeException('Not Found', 404);
}
$formName = $this->getPost('__form-name__');
if ($formName) {
/** @var Forms $forms */
$forms = $this->getGrav()['forms'];
$form = $forms->getActiveForm();
if ($form instanceof FlexForm && $form->getName() === $formName && $form->getObject()->getFlexKey() === $object->getFlexKey()) {
return $form;
}
}
return $object->getForm($type ?? 'edit');
}
/**
* @param FlexObjectInterface $object
* @param string $type
* @return FlexFormFlash
*/
protected function getFormFlash(FlexObjectInterface $object, string $type = '')
{
/** @var Uri $uri */
$uri = $this->grav['uri'];
$url = $uri->url;
$formName = $this->getPost('__form-name__');
if (!$formName) {
$form = $object->getForm($type);
$formName = $form->getName();
$uniqueId = $form->getUniqueId();
} else {
$uniqueId = $this->getPost('__unique_form_id__') ?: $formName ?: sha1($url);
}
/** @var Session $session */
$session = $this->grav['session'];
$config = [
'session_id' => $session->getId(),
'unique_id' => $uniqueId,
'form_name' => $formName,
];
$flash = new FlexFormFlash($config);
if (!$flash->exists()) {
$flash->setUrl($url)->setUser($this->grav['user']);
}
return $flash;
}
/**
* @return Grav
*/
public function getGrav(): Grav
{
return $this->grav;
}
/**
* @return Session
*/
public function getSession(): Session
{
return $this->grav['session'];
}
/**
* @return Flex
*/
public function getFlex(): Flex
{
return $this->grav['flex_objects'];
}
/**
* @return string
*/
public function getDirectoryType(): string
{
return $this->type;
}
/**
* @return string
*/
public function getObjectKey(): string
{
return $this->key;
}
/**
* @return FlexDirectory|null
*/
public function getDirectory(): ?FlexDirectory
{
return $this->directory;
}
/**
* @return FlexObjectInterface|null
*/
public function getObject(): ?FlexObjectInterface
{
return $this->object;
}
/**
* @param string $string
* @param array $args
* @return string
*/
public function translate(string $string, ...$args): string
{
/** @var Language $language */
$language = $this->grav['language'];
array_unshift($args, $string);
return $language->translate($args);
}
/**
* @param string $message
* @param string $type
* @return $this
*/
public function setMessage(string $message, string $type = 'info'): self
{
/** @var Message $messages */
$messages = $this->grav['messages'];
$messages->add($message, $type);
return $this;
}
/**
* @param UserInterface $user
* @return void
*/
public function setUser(UserInterface $user): void
{
$this->user = $user;
}
/**
* @return Config
*/
protected function getConfig(): Config
{
return $this->grav['config'];
}
/**
* @param string $task
* @return void
* @throws PageExpiredException
*/
protected function checkNonce(string $task): void
{
$nonce = null;
if (in_array(strtoupper($this->request->getMethod()), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
$nonce = $this->getPost($this->nonce_name);
}
if (!$nonce) {
$nonce = $this->grav['uri']->param($this->nonce_name);
}
if (!$nonce) {
$nonce = $this->grav['uri']->query($this->nonce_name);
}
if (!$nonce || !Utils::verifyNonce($nonce, $this->nonce_action)) {
throw new PageExpiredException($this->request);
}
}
}
@@ -0,0 +1,675 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Controllers;
use Exception;
use Grav\Common\Debugger;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Medium\Medium;
use Grav\Common\Page\Medium\MediumFactory;
use Grav\Common\Utils;
use Grav\Framework\Flex\FlexObject;
use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Framework\Media\Interfaces\MediaInterface;
use LogicException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
use RocketTheme\Toolbox\Event\Event;
use RuntimeException;
use function is_array;
use function is_string;
/**
* Class MediaController
* @package Grav\Plugin\FlexObjects\Controllers
*/
class MediaController extends AbstractController
{
/**
* @return ResponseInterface
*/
public function taskMediaUpload(): ResponseInterface
{
$this->checkAuthorization('media.create');
$object = $this->getObject();
if (null === $object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'checkUploadedMediaFile')) {
throw new RuntimeException('Not Found', 404);
}
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
// Get field for the uploaded media.
$field = $this->getPost('name', 'undefined');
if ($field === 'undefined') {
$field = null;
}
$request = $this->getRequest();
$files = $request->getUploadedFiles();
if ($field && isset($files['data'])) {
$files = $files['data'];
$parts = explode('.', $field);
$last = array_pop($parts);
foreach ($parts as $name) {
if (!is_array($files[$name])) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
}
$files = $files[$name];
}
$file = $files[$last] ?? null;
} else {
// Legacy call with name being the filename instead of field name.
$file = $files['file'] ?? null;
$field = null;
}
/** @var UploadedFileInterface $file */
if (is_array($file)) {
$file = reset($file);
}
if (!$file instanceof UploadedFileInterface) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
}
$filename = $file->getClientFilename();
$object->checkUploadedMediaFile($file, $filename, $field);
try {
// TODO: This only merges main level data, but is good for ordering (for now).
$data = $flash->getData() ?? [];
$data = array_replace($data, (array)$this->getPost('data'));
$crop = $this->getPost('crop');
if (is_string($crop)) {
$crop = json_decode($crop, true, 512, JSON_THROW_ON_ERROR);
}
$flash->setData($data);
$flash->addUploadedFile($file, $field, $crop);
$flash->save();
} catch (Exception $e) {
throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
}
// Include exif metadata into the response if configured to do so
$metadata = [];
$include_metadata = $this->grav['config']->get('system.media.auto_metadata_exif', false);
if ($include_metadata) {
$medium = MediumFactory::fromUploadedFile($file);
$media = $object->getMedia();
$media->add($filename, $medium);
$basename = str_replace(['@3x', '@2x'], '', Utils::pathinfo($filename, PATHINFO_BASENAME));
if (isset($media[$basename])) {
$metadata = $media[$basename]->metadata() ?: [];
}
}
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
'filename' => htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'metadata' => $metadata
];
return $this->createJsonResponse($response);
}
/**
* @return ResponseInterface
*/
public function taskMediaUploadMeta(): ResponseInterface
{
try {
$this->checkAuthorization('media.create');
$object = $this->getObject();
if (null === $object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'getMediaField')) {
throw new RuntimeException('Not Found', 404);
}
$object->refresh();
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
// Get field and data for the uploaded media.
$field = (string)$this->getPost('field');
$media = $object->getMediaField($field);
if (!$media) {
throw new RuntimeException('Media field not found: ' . $field, 404);
}
$data = $this->getPost('data');
if (is_string($data)) {
$data = json_decode($data, true);
}
$filename = Utils::basename($data['name'] ?? '');
// Update field.
$files = $object->getNestedProperty($field, []);
// FIXME: Do we want to save something into the field as well?
$files[$filename] = [];
$object->setNestedProperty($field, $files);
$info = [
'modified' => $data['modified'] ?? null,
'size' => $data['size'] ?? null,
'mime' => $data['mime'] ?? null,
'width' => $data['width'] ?? null,
'height' => $data['height'] ?? null,
'duration' => $data['duration'] ?? null,
'orientation' => $data['orientation'] ?? null,
'meta' => array_filter($data, static function ($val) { return $val !== null; })
];
$info = array_filter($info, static function ($val) { return $val !== null; });
// As the file may not be saved locally, we need to update the index.
$media->updateIndex([$filename => $info]);
$object->save();
$flash->save();
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
'field' => $field,
'filename' => $filename,
'metadata' => $data
];
} catch (\Exception $e) {
/** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
$debugger->addException($e);
return $this->createJsonErrorResponse($e);
}
return $this->createJsonResponse($response);
}
/**
* @return ResponseInterface
*/
public function taskMediaReorder(): ResponseInterface
{
try {
$this->checkAuthorization('media.update');
$object = $this->getObject();
if (null === $object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'getMediaField')) {
throw new RuntimeException('Not Found', 404);
}
$object->refresh();
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
// Get field and data for the uploaded media.
$field = (string)$this->getPost('field');
$media = $object->getMediaField($field);
if (!$media) {
throw new RuntimeException('Media field not found: ' . $field, 404);
}
// Create id => filename map from all files in the media.
$map = [];
foreach ($media as $name => $medium) {
$id = $medium->get('meta.id');
if ($id) {
$map[$id] = $name;
}
}
// Get reorder list and reorder the map.
$data = $this->getPost('data');
if (is_string($data)) {
$data = json_decode($data, true);
}
$data = array_fill_keys($data, null);
$map = array_filter(array_merge($data, $map), static function($val) { return $val !== null; });
// Reorder the files.
$files = $object->getNestedProperty($field, []);
$map = array_fill_keys($map, null);
$files = array_filter(array_merge($map, $files), static function($val) { return $val !== null; });
// Update field.
$object->setNestedProperty($field, $files);
$object->save();
$flash->save();
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FIELD_REORDER_SUCCESSFUL'),
'field' => $field,
'ordering' => array_keys($files)
];
} catch (\Exception $e) {
/** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
$debugger->addException($e);
$ex = new RuntimeException($this->translate('PLUGIN_ADMIN.FIELD_REORDER_FAILED', $field), $e->getCode(), $e);
return $this->createJsonErrorResponse($ex);
}
return $this->createJsonResponse($response);
}
/**
* @return ResponseInterface
*/
public function taskMediaDelete(): ResponseInterface
{
$this->checkAuthorization('media.delete');
/** @var FlexObjectInterface|null $object */
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
$filename = $this->getPost('filename');
// Handle bad filenames.
if (!Utils::checkFilename($filename)) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILE_FOUND'), 400);
}
try {
$field = $this->getPost('name');
$flash = $this->getFormFlash($object);
$flash->removeFile($filename, $field);
$flash->save();
} catch (Exception $e) {
throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
}
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_DELETED') . ': ' . htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8')
];
return $this->createJsonResponse($response);
}
/**
* Used in pagemedia field.
*
* @return ResponseInterface
*/
public function taskMediaCopy(): ResponseInterface
{
$this->checkAuthorization('media.create');
/** @var FlexObjectInterface|null $object */
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'uploadMediaFile')) {
throw new RuntimeException('Not Found', 404);
}
$request = $this->getRequest();
$files = $request->getUploadedFiles();
$file = $files['file'] ?? null;
if (!$file instanceof UploadedFileInterface) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
}
$post = $request->getParsedBody();
$filename = $post['name'] ?? $file->getClientFilename();
// Upload media right away.
$object->uploadMediaFile($file, $filename);
// Include exif metadata into the response if configured to do so
$metadata = [];
$include_metadata = $this->grav['config']->get('system.media.auto_metadata_exif', false);
if ($include_metadata) {
$basename = str_replace(['@3x', '@2x'], '', Utils::pathinfo($filename, PATHINFO_BASENAME));
$media = $object->getMedia();
if (isset($media[$basename])) {
$metadata = $media[$basename]->metadata() ?: [];
}
}
if ($object instanceof PageInterface) {
// Backwards compatibility to existing plugins.
// DEPRECATED: page
$this->grav->fireEvent('onAdminAfterAddMedia', new Event(['object' => $object, 'page' => $object]));
}
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
'filename' => htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'metadata' => $metadata
];
return $this->createJsonResponse($response);
}
/**
* Used in pagemedia field.
*
* @return ResponseInterface
*/
public function taskMediaRemove(): ResponseInterface
{
$this->checkAuthorization('media.delete');
/** @var FlexObjectInterface|null $object */
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'deleteMediaFile')) {
throw new RuntimeException('Not Found', 404);
}
$field = $this->getPost('field');
$filename = $this->getPost('filename');
// Handle bad filenames.
if (!Utils::checkFilename($filename)) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILE_FOUND'), 400);
}
$object->deleteMediaFile($filename, $field);
if ($field) {
$order = $object->getNestedProperty($field);
unset($order[$filename]);
$object->setNestedProperty($field, $order);
$object->save();
}
if ($object instanceof PageInterface) {
// Backwards compatibility to existing plugins.
// DEPRECATED: page
$this->grav->fireEvent('onAdminAfterDelMedia', new Event(['object' => $object, 'page' => $object, 'media' => $object->getMedia(), 'filename' => $filename]));
}
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_DELETED') . ': ' . htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8')
];
return $this->createJsonResponse($response);
}
/**
* @return ResponseInterface
*/
public function actionMediaList(): ResponseInterface
{
$this->checkAuthorization('media.list');
/** @var MediaInterface|FlexObjectInterface $object */
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
$media = $object->getMedia();
$media_list = [];
/**
* @var string $name
* @var Medium $medium
*/
foreach ($media->all() as $name => $medium) {
$media_list[$name] = [
'url' => ($thumb = $medium->display($medium->get('extension') === 'svg' ? 'source' : 'thumbnail')) ? $thumb->cropZoom(400, 300)->url() : '',
'size' => $medium->get('size'),
'metadata' => $medium->metadata() ?: [],
'original' => $medium->higherQualityAlternative()->get('filename')
];
}
$response = [
'code' => 200,
'status' => 'success',
'results' => $media_list
];
return $this->createJsonResponse($response);
}
/**
* Used by the filepicker field to get a list of files in a folder.
*
* @return ResponseInterface
*/
protected function actionMediaPicker(): ResponseInterface
{
$this->checkAuthorization('media.list');
/** @var FlexObject $object */
$object = $this->getObject();
if (!$object || !\is_callable([$object, 'getFieldSettings'])) {
throw new RuntimeException('Not Found', 404);
}
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
$name = $this->getPost('name');
$settings = $name ? $object->getFieldSettings($name) : null;
if (empty($settings['media_picker_field'])) {
throw new RuntimeException('Not Found', 404);
}
$media = $object->getMediaField($name);
$available_files = [];
$metadata = [];
$thumbs = [];
/**
* @var string $name
* @var Medium $medium
*/
foreach ($media->all() as $name => $medium) {
$available_files[] = $name;
if (isset($settings['include_metadata'])) {
$img_metadata = $medium->metadata();
if ($img_metadata) {
$metadata[$name] = $img_metadata;
}
}
}
// Peak in the flashObject for optimistic filepicker updates
$pending_files = [];
$sessionField = base64_encode($this->grav['uri']->url());
$flash = $this->getSession()->getFlashObject('files-upload');
$folder = $media->getPath() ?: null;
if ($flash && isset($flash[$sessionField])) {
foreach ($flash[$sessionField] as $field => $data) {
foreach ($data as $file) {
$test = \dirname($file['path']);
if ($test === $folder) {
$pending_files[] = $file['name'];
}
}
}
}
$this->getSession()->setFlashObject('files-upload', $flash);
// Handle Accepted file types
// Accept can only be file extensions (.pdf|.jpg)
if (isset($settings['accept'])) {
$available_files = array_filter($available_files, function ($file) use ($settings) {
return $this->filterAcceptedFiles($file, $settings);
});
$pending_files = array_filter($pending_files, function ($file) use ($settings) {
return $this->filterAcceptedFiles($file, $settings);
});
}
if (isset($settings['deny'])) {
$available_files = array_filter($available_files, function ($file) use ($settings) {
return $this->filterDeniedFiles($file, $settings);
});
$pending_files = array_filter($pending_files, function ($file) use ($settings) {
return $this->filterDeniedFiles($file, $settings);
});
}
// Generate thumbs if needed
if (isset($settings['preview_images']) && $settings['preview_images'] === true) {
foreach ($available_files as $filename) {
$thumbs[$filename] = $media[$filename]->zoomCrop(100,100)->url();
}
}
$response = [
'code' => 200,
'status' => 'success',
'files' => array_values($available_files),
'pending' => array_values($pending_files),
'folder' => $folder,
'metadata' => $metadata,
'thumbs' => $thumbs
];
return $this->createJsonResponse($response);
}
/**
* @param string $file
* @param array $settings
* @return false|int
*/
protected function filterAcceptedFiles(string $file, array $settings)
{
$valid = false;
foreach ((array)$settings['accept'] as $type) {
$find = str_replace('*', '.*', $type);
$valid |= preg_match('#' . $find . '$#i', $file);
}
return $valid;
}
/**
* @param string $file
* @param array $settings
* @return false|int
*/
protected function filterDeniedFiles(string $file, array $settings)
{
$valid = true;
foreach ((array)$settings['deny'] as $type) {
$find = str_replace('*', '.*', $type);
$valid = !preg_match('#' . $find . '$#i', $file);
}
return $valid;
}
/**
* @param string $action
* @return void
* @throws LogicException
* @throws RuntimeException
*/
protected function checkAuthorization(string $action): void
{
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
// If object does not have ACL support ignore ACL checks.
if (!$object instanceof FlexAuthorizeInterface) {
return;
}
switch ($action) {
case 'media.list':
$action = 'read';
break;
case 'media.create':
case 'media.update':
case 'media.delete':
$action = $object->exists() ? 'update' : 'create';
break;
default:
throw new LogicException(sprintf('Unsupported authorize action %s', $action), 500);
}
if (!$object->isAuthorized($action, null, $this->user)) {
throw new RuntimeException('Forbidden', 403);
}
}
}
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Controllers;
use Grav\Common\Grav;
use Grav\Common\Utils;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Framework\Media\Interfaces\MediaInterface;
use Grav\Framework\Psr7\Response;
use Grav\Framework\Psr7\Stream;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use function in_array;
use function is_string;
/**
* Public, permission-aware proxy for Flex Object media.
*
* PROTOTYPE (see docs/specs/media-proxy.md).
*
* Flex Objects store their data file and their uploaded media in the same
* folder under `user://data/<type>/<key>`. Historically the media was linked
* with a direct `/user/data/...` URL, which means the webserver — not Grav —
* decides who can read it, and a blanket deny on `user/data` breaks every
* image (getgrav/grav#4129).
*
* This controller serves a single media file through PHP after resolving the
* owning object and (optionally) checking its read ACL, so the data folder can
* stay private while public media still loads. It is the retrieval half of the
* "store in user://data, serve via a lightweight proxy" design.
*
* Route (registered in flex-objects.php, base configurable):
* GET /flex-media/<type>/<key>/<filename>[?field=<field>]
*/
final class MediaProxyController
{
/** Media types we are willing to serve inline. Mirrors the .htaccess allow-list. */
private const SERVEABLE = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'ico',
'mp4', 'webm', 'ogg', 'ogv', 'mov', 'mp3', 'wav', 'm4a', 'flac', 'pdf',
];
public function __construct(private readonly Grav $grav)
{
}
/**
* Build the public proxy URL for a media file on an object.
*
* Templates use this instead of `medium.url` while the proxy is opt-in;
* the core follow-up rewrites `Medium::url()` to emit it automatically.
*/
public static function url(FlexObjectInterface $object, string $filename, ?string $field = null): string
{
$grav = Grav::instance();
$base = (string) $grav['config']->get('plugins.flex-objects.media_proxy.base', '/flex-media');
$path = rtrim($base, '/')
. '/' . rawurlencode($object->getFlexType())
. '/' . rawurlencode($object->getKey())
. '/' . str_replace('%2F', '/', rawurlencode($filename));
if ($field !== null && $field !== '') {
$path .= '?field=' . rawurlencode($field);
}
return $grav['base_url'] . $path;
}
/**
* Resolve and stream the requested media file.
*
* @return ResponseInterface 200/206 with the file, or 304/403/404.
*/
public function serve(
string $type,
string $key,
string $filename,
?string $field,
ServerRequestInterface $request
): ResponseInterface {
$config = $this->grav['config'];
// Reject path traversal and hidden files outright.
if ($filename === '' || str_contains($filename, '..') || str_starts_with($filename, '.')) {
return $this->error(404);
}
$extension = strtolower(Utils::pathinfo($filename, PATHINFO_EXTENSION));
if (!in_array($extension, self::SERVEABLE, true)) {
// Never let the proxy hand out data files, databases, keys, etc.
return $this->error(404);
}
$flex = $this->grav['flex'] ?? null;
$object = $flex ? $flex->getObject($key, $type) : null;
if (!$object instanceof FlexObjectInterface || !$object->exists()) {
return $this->error(404);
}
// Permission gate. Off by default — the proxy currently exists to keep a
// single retrieval chokepoint, not to ACL-gate reads. When explicitly
// enabled, only an explicit "no" blocks the file, so directories without
// a read ACL keep behaving as public media.
if ($config->get('plugins.flex-objects.media_proxy.authorize', false)) {
$user = $this->grav['user'] ?? null;
if ($object->isAuthorized('read', 'frontend', $user) === false) {
return $this->error(403);
}
}
// Resolve the media collection (field-scoped or the object's own media).
$media = $field
? (method_exists($object, 'getMediaField') ? $object->getMediaField($field) : null)
: ($object instanceof MediaInterface ? $object->getMedia() : null);
$medium = $media[$filename] ?? null;
if ($medium === null) {
return $this->error(404);
}
$filepath = $medium->get('filepath');
if (!is_string($filepath) || !is_file($filepath)) {
return $this->error(404);
}
return $this->stream($filepath, $medium->get('mime') ?: null, $request, $config);
}
/**
* Stream a file with caching, conditional-GET (304) and single-range (206) support.
*/
private function stream(string $filepath, ?string $mime, ServerRequestInterface $request, $config): ResponseInterface
{
$size = (int) filesize($filepath);
$mtime = (int) filemtime($filepath);
$etag = '"' . dechex($mtime) . '-' . dechex($size) . '"';
$mime = $mime ?: (Utils::getMimeByExtension(Utils::pathinfo($filepath, PATHINFO_EXTENSION), 'application/octet-stream'));
$cacheControl = (string) $config->get('plugins.flex-objects.media_proxy.cache_control', 'public, max-age=604800');
$baseHeaders = [
'Content-Type' => $mime,
'Cache-Control' => $cacheControl,
'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT',
'ETag' => $etag,
'Accept-Ranges' => 'bytes',
// Defense in depth: never let a served file be interpreted as a document.
'X-Content-Type-Options' => 'nosniff',
'Content-Disposition' => 'inline; filename="' . basename($filepath) . '"',
];
// Conditional GET — return 304 when the client's copy is still fresh.
$ifNoneMatch = $request->getHeaderLine('If-None-Match');
$ifModifiedSince = $request->getHeaderLine('If-Modified-Since');
if (($ifNoneMatch !== '' && trim($ifNoneMatch) === $etag)
|| ($ifModifiedSince !== '' && @strtotime($ifModifiedSince) >= $mtime)) {
return new Response(304, $baseHeaders);
}
// Single-range support (enough for video/audio seeking).
$range = $request->getHeaderLine('Range');
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', trim($range), $m)) {
$start = $m[1] === '' ? null : (int) $m[1];
$end = $m[2] === '' ? null : (int) $m[2];
if ($start === null && $end !== null) { // suffix range: last N bytes
$start = max(0, $size - $end);
$end = $size - 1;
} else {
$start ??= 0;
$end = $end === null ? $size - 1 : min($end, $size - 1);
}
if ($start > $end || $start >= $size) {
return new Response(416, $baseHeaders + ['Content-Range' => "bytes */$size"]);
}
$length = $end - $start + 1;
$fh = fopen($filepath, 'rb');
fseek($fh, $start);
$body = stream_get_contents($fh, $length);
fclose($fh);
return new Response(206, $baseHeaders + [
'Content-Range' => "bytes $start-$end/$size",
'Content-Length' => (string) $length,
], $body);
}
// Full file — stream the resource so we don't buffer it all in memory.
$body = Stream::create(fopen($filepath, 'rb'));
return new Response(200, $baseHeaders + ['Content-Length' => (string) $size], $body);
}
private function error(int $code): ResponseInterface
{
$text = [403 => 'Forbidden', 404 => 'Not Found'][$code] ?? 'Error';
return new Response($code, [
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'no-store',
], $text);
}
}
@@ -0,0 +1,543 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Controllers;
use Grav\Common\Grav;
use Grav\Framework\Flex\FlexForm;
use Grav\Framework\Flex\FlexObject;
use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface;
use Grav\Framework\Route\Route;
use Grav\Plugin\FlexObjects\Events\FlexTaskEvent;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
use RuntimeException;
/**
* Object controller is for the frontend.
*
* Currently following tasks are supported:
*
* - save (create or update)
* - create
* - update
* - delete
* - reset
* - preview
*/
class ObjectController extends AbstractController
{
/**
* Save object.
*
* Forwards call to either create or update task.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskSave(ServerRequestInterface $request): ResponseInterface
{
$form = $this->getForm();
$object = $form->getObject();
return $object->exists() ? $this->taskUpdate($request) : $this->taskCreate($request);
}
/**
* Create object.
*
* Task fails if object exists.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskCreate(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('create');
$form = $this->getForm();
$callable = function (array $data, array $files, FlexObject $object) {
if (method_exists($object, 'storeOriginal')) {
$object->storeOriginal();
}
$object->update($data, $files);
if (\is_callable([$object, 'check'])) {
$object->check($this->user);
}
$event = new FlexTaskEvent($this, $object, 'create');
$this->grav->dispatchEvent($event);
$object->save();
};
$form->setSubmitMethod($callable);
$form->handleRequest($request);
if (!$form->isValid()) {
$error = $form->getError();
if ($error) {
$this->setMessage($error, 'error');
}
$errors = $form->getErrors();
foreach ($errors as $field) {
foreach ($field as $error) {
$this->setMessage($error, 'error');
}
}
$data = $form->getData();
if (null !== $data) {
$object = $form->getObject();
$flash = $form->getFlash();
$flash->setObject($object);
$flash->setData($data->toArray());
$flash->save();
}
return $this->createDisplayResponse();
}
// FIXME: make it conditional
$grav = $this->grav;
$grav->fireEvent('gitsync');
$this->object = $form->getObject();
$event = new Event(
[
'task' => 'create',
'controller' => $this,
'object' => $this->object,
'response' => null,
'message' => null,
]
);
$this->grav->fireEvent("flex.{$this->type}.task.create.after", $event);
$this->setMessage($event['message'] ?? $this->translate('PLUGIN_FLEX_OBJECTS.STATE.CREATED_SUCCESSFULLY'), 'info');
if ($event['response']) {
return $event['response'];
}
$redirect = $request->getAttribute('redirect', (string)$request->getUri());
return $this->createRedirectResponse($redirect, 303);
}
/**
* Update object.
*
* Task fails if object does not exist.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskUpdate(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('update');
$form = $this->getForm();
$callable = function (array $data, array $files, FlexObject $object) {
if (method_exists($object, 'storeOriginal')) {
$object->storeOriginal();
}
$object->update($data, $files);
if (\is_callable([$object, 'check'])) {
$object->check($this->user);
}
$event = new FlexTaskEvent($this, $object, 'update');
$this->grav->dispatchEvent($event);
$object->save();
};
$form->setSubmitMethod($callable);
$form->handleRequest($request);
if (!$form->isValid()) {
$error = $form->getError();
if ($error) {
$this->setMessage($error, 'error');
}
$errors = $form->getErrors();
foreach ($errors as $field) {
foreach ($field as $error) {
$this->setMessage($error, 'error');
}
}
$data = $form->getData();
if (null !== $data) {
$object = $form->getObject();
$flash = $form->getFlash();
$flash->setObject($object);
$flash->setData($data->toArray());
$flash->save();
}
return $this->createDisplayResponse();
}
// FIXME: make it conditional
$grav = $this->grav;
$grav->fireEvent('gitsync');
$this->object = $form->getObject();
$event = new Event(
[
'task' => 'update',
'controller' => $this,
'object' => $this->object,
'response' => null,
'message' => null,
]
);
$this->grav->fireEvent("flex.{$this->type}.task.update.after", $event);
$this->setMessage($event['message'] ?? $this->translate('PLUGIN_FLEX_OBJECTS.STATE.UPDATED_SUCCESSFULLY'), 'info');
if ($event['response']) {
return $event['response'];
}
$redirect = $request->getAttribute('redirect', (string)$request->getUri()->getPath());
return $this->createRedirectResponse($redirect, 303);
}
/**
* Delete object.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskDelete(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('delete');
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
$event = new FlexTaskEvent($this, $object, 'delete');
$this->grav->dispatchEvent($event);
$object->delete();
// FIXME: make it conditional
$grav = $this->grav;
$grav->fireEvent('gitsync');
$event = new Event(
[
'task' => 'delete',
'controller' => $this,
'object' => $object,
'response' => null,
'message' => null,
]
);
$this->grav->fireEvent("flex.{$this->type}.task.delete.after", $event);
$this->setMessage($this->translate($event['message'] ?? 'PLUGIN_FLEX_OBJECTS.STATE.DELETED_SUCCESSFULLY'), 'info');
if ($event['response']) {
return $event['response'];
}
$redirect = $request->getAttribute('redirect', (string)$request->getUri()->getPath());
return $this->createRedirectResponse($redirect, 303);
}
/**
* Reset form to original values.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskReset(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('save');
$flash = $this->getForm()->getFlash();
$flash->delete();
$redirect = $request->getAttribute('redirect', (string)$request->getUri()->getPath());
return $this->createRedirectResponse($redirect, 303);
}
/**
* Preview object.
*
* Takes a form input and converts it to visible presentation of the object.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskPreview(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('save');
/** @var FlexForm $form */
$form = $this->getForm('edit');
$form->setRequest($request);
if (!$form->validate()) {
$error = $form->getError();
if ($error) {
$this->setMessage($error, 'error');
}
$errors = $form->getErrors();
foreach ($errors as $field) {
foreach ($field as $error) {
$this->setMessage($error, 'error');
}
}
return $this->createRedirectResponse((string)$request->getUri(), 303);
}
$this->object = $form->updateObject();
return $this->actionDisplayPreview();
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaList(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('action', 'media.list');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaUpload(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('task', 'media.upload');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaUploadMeta(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('task', 'media.upload.meta');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaReorder(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('task', 'media.reorder');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaDelete(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('task', 'media.delete');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskGetFilesInFolder(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('action', 'media.picker');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
* @deprecated Do not use
*/
public function taskFilesUpload(ServerRequestInterface $request): ResponseInterface
{
/** @var Route $route */
$route = $this->grav['route'];
if ($route->getParam('task') === 'media.upload') {
return $this->taskMediaUpload($request);
}
throw new RuntimeException('Task filesUpload should not be called, please update form plugin!', 400);
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
* @deprecated Do not use
*/
public function taskRemoveMedia(ServerRequestInterface $request): ResponseInterface
{
/** @var Route $route */
$route = $this->grav['route'];
if ($route->getParam('task') === 'media.delete') {
return $this->taskMediaDelete($request);
}
throw new RuntimeException('Task removeMedia should not be called, please update form plugin!', 400);
}
/**
* Display object preview.
*
* @return ResponseInterface
*/
public function actionDisplayPreview(): ResponseInterface
{
$this->checkAuthorization('save');
$this->checkAuthorization('read');
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('No object found!', 404);
}
$grav = Grav::instance();
$grav['twig']->init();
$grav['theme'];
$content = [
'code' => 200,
'id' => $object->getKey(),
'exists' => $object->exists(),
'html' => (string)$object->render('preview', ['nocache' => []])
];
$accept = $this->getAccept(['application/json', 'text/html']);
if ($accept === 'text/html') {
return $this->createHtmlResponse($content['html']);
}
if ($accept === 'application/json') {
return $this->createJsonResponse($content);
}
throw new RuntimeException('Not found', 404);
}
/**
* @param string $action
* @param string|null $scope
* @return void
* @throws RuntimeException
*/
public function checkAuthorization(string $action, ?string $scope = null): void
{
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
if ($object instanceof FlexAuthorizeInterface) {
if (!$object->isAuthorized($action, $scope, $this->user)) {
throw new RuntimeException('Forbidden', 403);
}
}
}
/**
* @param string[] $actions
* @return void
* @throws RuntimeException
*/
public function checkAuthorizations(array $actions): void
{
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
if ($object instanceof FlexAuthorizeInterface) {
$test = false;
foreach ($actions as $action) {
$test |= $object->isAuthorized($action, null, $this->user);
}
if (!$test) {
throw new RuntimeException('Forbidden', 403);
}
}
}
/**
* @param string $type
* @param string $name
* @return ResponseInterface
*/
protected function forwardMediaTask(string $type, string $name): ResponseInterface
{
/** @var Route $route */
$route = $this->grav['route']->withGravParam('task', null)->withGravParam($type, $name);
$object = $this->getObject();
/** @var ServerRequest $request */
$request = $this->grav['request'];
$request = $request
->withAttribute($type, $name)
->withAttribute('type', $this->type)
->withAttribute('key', $this->key)
->withAttribute('storage_key', $object && $object->exists() ? $object->getStorageKey() : null)
->withAttribute('route', $route)
->withAttribute('forwarded', true)
->withAttribute('object', $object);
$controller = new MediaController();
if ($this->user) {
$controller->setUser($this->user);
}
return $controller->handle($request);
}
}
@@ -0,0 +1,84 @@
<?php
namespace Grav\Plugin\FlexObjects\Events;
use Grav\Framework\Flex\Interfaces\FlexCollectionInterface;
use Grav\Framework\Flex\Interfaces\FlexDirectoryInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Framework\Object\Interfaces\ObjectInterface;
use Grav\Plugin\FlexObjects\Controllers\AbstractController;
/**
* @template T as FlexObjectInterface
* @template C as FlexCollectionInterface
*/
class FlexTaskEvent
{
/** @var string */
public $task;
/** @var string */
public $type;
/** @var string */
public $key;
/** @var ObjectInterface */
private $object;
/** @var AbstractController */
private $controller;
/**
* @param AbstractController $controller
* @param string $task
*/
public function __construct(AbstractController $controller, ObjectInterface $object, string $task)
{
$this->task = $task;
$this->type = $controller->getDirectoryType();
$this->key = $controller->getObjectKey();
$this->object = $object;
$this->controller = $controller;
}
/**
* @return AbstractController
*/
public function getController(): AbstractController
{
return $this->controller;
}
/**
* @return FlexDirectoryInterface
*/
public function getDirectory(): FlexDirectoryInterface
{
return $this->getController()->getDirectory();
}
/**
* @return FlexObjectInterface
* @phpstan-return T
*/
public function getModifiedObject(): FlexObjectInterface
{
return $this->object;
}
/**
* @return FlexObjectInterface
* @phpstan-return T
*/
public function getOriginalObject(): FlexObjectInterface
{
return $this->controller->getObject();
}
/**
* @return FlexCollectionInterface
* @phpstan-return C
*/
public function getCollection(): FlexCollectionInterface
{
return $this->getController()->getDirectory()->getCollection();
}
}
+444
View File
@@ -0,0 +1,444 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects;
use Grav\Common\Config\Config;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Utils;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Framework\Flex\FlexObject;
use Grav\Framework\Flex\Interfaces\FlexCollectionInterface;
use Grav\Framework\Flex\Interfaces\FlexCommonInterface;
use Grav\Framework\Flex\Interfaces\FlexDirectoryInterface;
use Grav\Framework\Flex\Interfaces\FlexInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Plugin\FlexObjects\Admin\AdminController;
use Grav\Plugin\FlexObjects\Table\DataTable;
/**
* Class Flex
* @package Grav\Plugin\FlexObjects
*/
class Flex implements FlexInterface
{
/** @var FlexInterface */
protected $flex;
/** @var array */
protected $adminRoutes;
/** @var array */
protected $adminMenu;
/** @var array */
protected $managed;
/**
* @param bool $newToOld
* @return array
* @internal
*/
public static function getLegacyBlueprintMap(bool $newToOld = true): array
{
$map = [
'blueprints://flex-objects/pages.yaml' => 'blueprints://flex-objects/grav-pages.yaml',
'blueprints://flex-objects/user-accounts.yaml' => 'blueprints://flex-objects/grav-accounts.yaml',
'blueprints://flex-objects/user-groups.yaml' => 'blueprints://flex-objects/grav-user-groups.yaml'
];
return $newToOld ? $map : array_flip($map);
}
/**
* Flex constructor.
* @param FlexInterface $flex
* @param array $types
*/
public function __construct(FlexInterface $flex, array $types)
{
$this->flex = $flex;
$this->managed = [];
$legacy = static::getLegacyBlueprintMap(false);
foreach ($types as $blueprint) {
// Backwards compatibility to v1.0.0-rc.3
$blueprint = $legacy[$blueprint] ?? $blueprint;
$type = Utils::basename((string)$blueprint, '.yaml');
if ($type) {
$this->managed[] = $type;
}
}
}
/**
* @param string $type
* @param string $blueprint
* @param array $config
* @return $this
*/
public function addDirectoryType(string $type, string $blueprint, array $config = [])
{
$this->flex->addDirectoryType($type, $blueprint, $config);
return $this;
}
/**
* @param FlexDirectory $directory
* @return $this
*/
public function addDirectory(FlexDirectory $directory)
{
$this->flex->addDirectory($directory);
return $this;
}
/**
* @param string $type
* @return bool
*/
public function hasDirectory(string $type): bool
{
return $this->flex->hasDirectory($type);
}
/**
* @param string[]|null $types
* @param bool $keepMissing
* @return array<FlexDirectoryInterface|null>
*/
public function getDirectories(?array $types = null, bool $keepMissing = false): array
{
return $this->flex->getDirectories($types, $keepMissing);
}
/**
* Get directories which are not hidden in the site.
*
* @return array
*/
public function getDefaultDirectories(): array
{
$list = $this->getDirectories();
foreach ($list as $type => $directory) {
if ($directory->getConfig('site.hidden', false)) {
unset($list[$type]);
}
}
return $list;
}
/**
* @param string $type
* @return FlexDirectory|null
*/
public function getDirectory(string $type): ?FlexDirectory
{
return $this->flex->getDirectory($type);
}
/**
* @param string $type
* @param array|null $keys
* @param string|null $keyField
* @return FlexCollectionInterface|null
*/
public function getCollection(string $type, ?array $keys = null, ?string $keyField = null): ?FlexCollectionInterface
{
return $this->flex->getCollection($type, $keys, $keyField);
}
/**
* @param array $keys
* @param array $options In addition to the options in getObjects(), following options can be passed:
* collection_class: Class to be used to create the collection. Defaults to ObjectCollection.
* @return FlexCollectionInterface
* @throws \RuntimeException
*/
public function getMixedCollection(array $keys, array $options = []): FlexCollectionInterface
{
return $this->flex->getMixedCollection($keys, $options);
}
/**
* @param array $keys
* @param array $options Following optional options can be passed:
* types: List of allowed types.
* type: Allowed type if types isn't defined, otherwise acts as default_type.
* default_type: Set default type for objects given without type (only used if key_field isn't set).
* keep_missing: Set to true if you want to return missing objects as null.
* key_field: Key field which is used to match the objects.
* @return array
*/
public function getObjects(array $keys, array $options = []): array
{
return $this->flex->getObjects($keys, $options);
}
/**
* @param string $key
* @param string|null $type
* @param string|null $keyField
* @return FlexObjectInterface|null
*/
public function getObject(string $key, ?string $type = null, ?string $keyField = null): ?FlexObjectInterface
{
return $this->flex->getObject($key, $type, $keyField);
}
/**
* @return int
*/
public function count(): int
{
return $this->flex->count();
}
public function isManaged(string $type): bool
{
return \in_array($type, $this->managed, true);
}
/**
* @return array
*/
public function getAll(): array
{
$directories = $this->getDirectories($this->managed);
$all = $this->getBlueprints();
/** @var FlexDirectory $directory */
foreach ($all as $type => $directory) {
if (!isset($directories[$type])) {
$directories[$type] = $directory;
}
}
ksort($directories);
return $directories;
}
/**
* @return array
*/
public function getBlueprints(): array
{
$params = [
'pattern' => '|\.yaml|',
'value' => 'Url',
'recursive' => false,
'folders' => false
];
$directories = [];
$all = Folder::all('blueprints://flex-objects', $params);
foreach ($all as $url) {
$type = Utils::basename($url, '.yaml');
$directory = new FlexDirectory($type, $url);
if ($directory->getConfig('hidden') !== true) {
$directories[$type] = $directory;
}
}
// Order blueprints by title.
usort($directories, static function (FlexDirectory $a, FlexDirectory $b) {
return $a->getTitle() <=> $b->getTitle();
});
return $directories;
}
/**
* @param string|FlexDirectory $type
* @param array $options
* @return DataTable
*/
public function getDataTable($type, array $options = []): DataTable
{
$directory = $type instanceof FlexDirectory ? $type : $this->getDirectory($type);
if (!$directory) {
throw new \RuntimeException('Not Found', 404);
}
$collection = $options['collection'] ?? $directory->getCollection();
if (isset($options['filters']) && is_array($options['filters'])) {
$collection = $collection->filterBy($options['filters']);
}
$table = new DataTable($options);
$table->setCollection($collection);
return $table;
}
/**
* @param string|object|null $type
* @param array $params
* @param string $extension
* @return string
*/
public function adminRoute($type = null, array $params = [], string $extension = ''): string
{
if (\is_object($type)) {
$object = $type;
if ($object instanceof FlexCommonInterface || $object instanceof FlexDirectory) {
$type = $type->getFlexType();
} else {
return '';
}
} else {
$object = null;
}
$routes = $this->getAdminRoutes();
$grav = Grav::instance();
/** @var Config $config */
$config = $grav['config'];
if (!Utils::isAdminPlugin()) {
$parts = [
trim($grav['base_url'], '/'),
trim($config->get('plugins.admin.route'), '/')
];
}
if ($type && isset($routes[$type])) {
if (!$routes[$type]) {
// Directory has empty route.
return '';
}
// Directory has it's own menu item.
$parts[] = trim($routes[$type], '/');
} else {
if (empty($routes[''])) {
// Default route has been disabled.
return '';
}
// Use default route.
$parts[] = trim($routes[''], '/');
if ($type) {
$parts[] = $type;
}
}
// Append object key if available.
if ($object instanceof FlexObject) {
if ($object->exists()) {
$parts[] = trim($object->getKey(), '/');
} else {
if ($object->hasKey()) {
$parts[] = trim($object->getKey(), '/');
}
$params = ['' => 'add'] + $params;
}
}
$p = [];
$separator = $config->get('system.param_sep');
foreach ($params as $key => $val) {
$p[] = $key . $separator . $val;
}
$parts = array_filter($parts, static function ($val) { return $val !== ''; });
$route = '/' . implode('/', $parts);
$extension = $extension ? '.' . $extension : '';
return $route . $extension . ($p ? '/' . implode('/', $p) : '');
}
public function getAdminController(): ?AdminController
{
$grav = Grav::instance();
if (!isset($grav['admin'])) {
return null;
}
/** @var PageInterface $page */
$page = $grav['page'];
$header = $page->header();
$callable = $header->controller['controller']['instance'] ?? null;
if (null !== $callable && \is_callable($callable)) {
return $callable();
}
return null;
}
/**
* @return array
*/
public function getAdminRoutes(): array
{
if (null === $this->adminRoutes) {
$routes = [];
/** @var FlexDirectory $directory */
foreach ($this->getDirectories() as $directory) {
$config = $directory->getConfig('admin');
if (!$directory->isEnabled() || !empty($config['disabled'])) {
continue;
}
// Resolve route.
$route = $config['router']['path']
?? $config['menu']['list']['route']
?? "/flex-objects/{$directory->getFlexType()}";
$routes[$directory->getFlexType()] = $route;
}
$this->adminRoutes = $routes;
}
return $this->adminRoutes;
}
/**
* @return array
*/
public function getAdminMenuItems(): array
{
if (null === $this->adminMenu) {
$routes = [];
$count = 0;
$directories = $this->getDirectories();
/** @var FlexDirectory $directory */
foreach ($directories as $directory) {
$config = $directory->getConfig('admin');
if (!$directory->isEnabled() || !empty($config['disabled'])) {
continue;
}
$type = $directory->getFlexType();
$items = $directory->getConfig('admin.menu') ?? [];
if ($items) {
foreach ($items as $view => $item) {
$item += [
'route' => '/' . $type,
'title' => $directory->getTitle(),
'icon' => 'fa fa-file',
'directory' => $type
];
$routes[$type] = $item;
}
} else {
$count++;
}
}
if ($count && !isset($routes[''])) {
$routes[''] = ['route' => '/flex-objects'];
}
$this->adminMenu = $routes;
}
return $this->adminMenu;
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Page;
use Grav\Framework\Form\Interfaces\FormFactoryInterface;
use Grav\Framework\Form\Interfaces\FormInterface;
use RocketTheme\Toolbox\Event\Event;
use function is_callable;
use function is_string;
/**
* Class FlexFormFactory
* @package Grav\Plugin\FlexObjects
*/
class FlexFormFactory implements FormFactoryInterface
{
/**
* @param Page $page
* @param string $name
* @param array $form
* @return FormInterface|null
*/
public function createPageForm(Page $page, string $name, array $form): ?FormInterface
{
return $this->createFormForPage($page, $name, $form);
}
/**
* @param PageInterface $page
* @param string $name
* @param array $form
* @return FormInterface|null
*/
public function createFormForPage(PageInterface $page, string $name, array $form): ?FormInterface
{
// Fire event
$grav = Grav::instance();
$grav->fireEvent('onBeforeFlexFormInitialize', new Event(['page' => $page, 'name' => $name, 'form' => &$form]));
$page->addForms([$form], true);
$formFlex = $form['flex'] ?? [];
$type = $formFlex['type'] ?? null;
$key = $formFlex['key'] ?? null;
if (null !== $key && !is_string($key)) {
$key = (string)$key;
}
$layout = $formFlex['layout'] ?? $name;
/** @var Flex $flex */
$flex = Grav::instance()['flex_objects'];
if (is_string($type)) {
$directory = $flex->getDirectory($type);
if (!$directory) {
return null;
}
$create = $form['actions']['create'] ?? true;
$edit = $form['actions']['edit'] ?? true;
$object = $edit && null !== $key ? $directory->getObject($key) : null;
if ($object) {
if (is_callable([$object, 'refresh'])) {
$object->refresh();
}
} elseif ($create) {
$object = $directory->createObject([], $key ?? '');
}
} else {
$object = $flex->getObject($key);
}
return $object ? $object->getForm($layout, ['form' => $form]) : null;
}
}
@@ -0,0 +1,64 @@
<?php
namespace Grav\Plugin\FlexObjects;
use Grav\Framework\Route\Route;
use Grav\Plugin\FlexObjects\Controllers\MediaController;
use Grav\Plugin\FlexObjects\Controllers\ObjectController;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Class FlexRouter
* @package Grav\Plugin\FlexObjects
*/
class FlexRouter implements MiddlewareInterface
{
/**
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler
* @return ResponseInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$context = $request->getAttributes();
/** @var Route $route */
$route = $context['route'];
$post = $request->getParsedBody();
$task = $post['task'] ?? $route->getParam('task');
if (\in_array($task, ['cropupload', 'filesupload'])) {
$task = 'media.upload';
}
switch ($task) {
case 'media.upload':
case 'media.delete':
case 'media.copy':
case 'media.remove':
case 'media.list':
case 'media.add':
case 'listmedia':
case 'addmedia':
case 'delmedia':
return (new MediaController())->handle($request);
case 'save':
case 'create':
case 'update':
case 'delete':
case 'reset':
case 'preview':
case 'move':
return (new ObjectController())->handle($request);
}
// No handler found.
return $handler->handle($request);
}
}
@@ -0,0 +1,405 @@
<?php declare(strict_types=1);
namespace Grav\Plugin\FlexObjects;
use Grav\Framework\Flex\Interfaces\FlexStorageInterface;
use PDO;
use PDOException;
/**
* Optional SQLite-backed index for Flex Objects.
*
* Provides atomic upserts, WAL-mode concurrency, and indexed SQL queries
* as an alternative to the default YAML flat-file index. Falls back
* gracefully when pdo_sqlite is not available.
*
* The schema is generic (flex_index + flex_meta tables) but individual
* Flex types define which columns exist via a column definition array
* passed to the constructor.
*/
class SqliteIndexBackend
{
/** @var string */
private $dbPath;
/** @var string */
private $schemaVersion;
/** @var PDO|null */
private $pdo;
/**
* @var array Column definitions for the flex_index table.
*
* Each entry is ['name' => string, 'type' => string, 'default' => string|null].
* The core columns (storage_key, storage_timestamp, checksum, key) are always
* present. Additional columns are type-specific and supplied by the caller.
*/
private $columns;
/** @var array Index definitions as ['name' => 'columns_sql'] */
private $indexes;
/**
* @param string $dbPath Absolute path to the SQLite database file
* @param string $schemaVersion Version string stored in flex_meta for rebuild detection
* @param array $columns Extra column definitions beyond the core four.
* Each entry: ['name' => string, 'type' => string, 'default' => string|null]
* @param array $indexes Index definitions as ['index_name' => 'col1, col2']
*/
public function __construct(string $dbPath, string $schemaVersion, array $columns = [], array $indexes = [])
{
$this->dbPath = $dbPath;
$this->schemaVersion = $schemaVersion;
$this->columns = $columns;
$this->indexes = $indexes;
}
public static function isAvailable(): bool
{
return extension_loaded('pdo_sqlite');
}
public function needsRebuild(string $expectedVersion): bool
{
try {
$pdo = $this->connect();
$version = $this->getSchemaVersion($pdo);
return $version !== $expectedVersion;
} catch (PDOException $e) {
return true;
}
}
/**
* @return array<string, array> Returns [storage_key => meta_array, ...]
*/
public function getAllEntries(): array
{
try {
$pdo = $this->connect();
$stmt = $pdo->query('SELECT * FROM flex_index ORDER BY storage_key');
$entries = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$key = $row['storage_key'];
$entries[$key] = $this->rowToMeta($row);
}
return $entries;
} catch (PDOException $e) {
return [];
}
}
/**
* @param string $where SQL WHERE clause (use ? placeholders)
* @param array $params Bound parameter values
* @return string[] Matching storage_keys
*/
public function queryKeys(string $where, array $params = []): array
{
try {
$pdo = $this->connect();
$stmt = $pdo->prepare("SELECT storage_key FROM flex_index WHERE {$where}");
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
} catch (PDOException $e) {
return [];
}
}
public function upsertEntry(string $key, array $meta): void
{
try {
$pdo = $this->connect();
$this->doUpsert($pdo, $key, $meta);
} catch (PDOException $e) {
// Silently fail — caller should fall back to YAML
}
}
/**
* @param array<string, array> $entries [storage_key => meta_array, ...]
*/
public function upsertEntries(array $entries): void
{
try {
$pdo = $this->connect();
$pdo->beginTransaction();
foreach ($entries as $key => $meta) {
$this->doUpsert($pdo, (string)$key, $meta);
}
$pdo->commit();
} catch (PDOException $e) {
if ($this->pdo && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
}
}
public function removeEntry(string $key): void
{
try {
$pdo = $this->connect();
$stmt = $pdo->prepare('DELETE FROM flex_index WHERE storage_key = ?');
$stmt->execute([$key]);
} catch (PDOException $e) {
// Silently fail
}
}
/**
* @param string[] $keys
*/
public function removeEntries(array $keys): void
{
if (!$keys) {
return;
}
try {
$pdo = $this->connect();
$placeholders = implode(',', array_fill(0, count($keys), '?'));
$stmt = $pdo->prepare("DELETE FROM flex_index WHERE storage_key IN ({$placeholders})");
$stmt->execute(array_values($keys));
} catch (PDOException $e) {
// Silently fail
}
}
/**
* Full rebuild: drops all rows, re-scans storage, re-inserts everything.
*
* @param FlexStorageInterface $storage
* @param callable $metaBuilder Callable that receives (&$meta, $data, $storage)
*/
public function rebuild(FlexStorageInterface $storage, callable $metaBuilder): void
{
try {
$pdo = $this->connect();
// Get all existing keys from storage
$existingKeys = $storage->getExistingKeys();
if (!$existingKeys) {
$pdo->exec('DELETE FROM flex_index');
$this->setSchemaVersion($pdo, $this->schemaVersion);
return;
}
// Read all data rows in chunks
$allEntries = [];
$chunks = array_chunk($existingKeys, 100, true);
foreach ($chunks as $chunk) {
$keys = array_fill_keys(array_keys($chunk), null);
$rows = $storage->readRows($keys);
$keyField = $storage->getKeyField();
foreach ($rows as $storageKey => $row) {
if ($row === null) {
continue;
}
$entry = $existingKeys[$storageKey] + ['key' => $storageKey];
if ($keyField !== 'storage_key' && isset($row[$keyField])) {
$entry['key'] = $row[$keyField];
}
$metaBuilder($entry, $row, $storage);
$allEntries[$storageKey] = $entry;
}
}
// Replace all data in a single transaction
$pdo->beginTransaction();
$pdo->exec('DELETE FROM flex_index');
foreach ($allEntries as $key => $meta) {
$this->doUpsert($pdo, (string)$key, $meta);
}
$this->setSchemaVersion($pdo, $this->schemaVersion);
$pdo->commit();
} catch (PDOException $e) {
if ($this->pdo && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
}
}
/**
* @return int Number of entries in the index
*/
public function count(): int
{
try {
$pdo = $this->connect();
$result = $pdo->query('SELECT COUNT(*) FROM flex_index');
return (int)$result->fetchColumn();
} catch (PDOException $e) {
return 0;
}
}
// -- Internal --------------------------------------------------------
private function connect(): PDO
{
if ($this->pdo !== null) {
return $this->pdo;
}
$dir = dirname($this->dbPath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$pdo = new PDO('sqlite:' . $this->dbPath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA synchronous=NORMAL');
$this->ensureSchema($pdo);
$this->pdo = $pdo;
return $pdo;
}
private function ensureSchema(PDO $pdo): void
{
// Check if tables exist
$result = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='flex_index'");
if ($result->fetchColumn() !== false) {
return;
}
// Build column definitions: core + type-specific
$colDefs = [
'storage_key TEXT PRIMARY KEY',
'storage_timestamp INTEGER NOT NULL',
'checksum TEXT',
'key TEXT NOT NULL',
];
foreach ($this->columns as $col) {
$line = $col['name'] . ' ' . $col['type'];
if (isset($col['default'])) {
$line .= ' DEFAULT ' . $col['default'];
}
$colDefs[] = $line;
}
$pdo->exec('CREATE TABLE flex_index (' . implode(', ', $colDefs) . ')');
// Create indexes
foreach ($this->indexes as $name => $columnsSql) {
$pdo->exec("CREATE INDEX {$name} ON flex_index({$columnsSql})");
}
$pdo->exec(<<<'SQL'
CREATE TABLE flex_meta (
meta_key TEXT PRIMARY KEY,
meta_value TEXT
)
SQL
);
}
private function getSchemaVersion(PDO $pdo): ?string
{
try {
$stmt = $pdo->prepare('SELECT meta_value FROM flex_meta WHERE meta_key = ?');
$stmt->execute(['schema_version']);
$value = $stmt->fetchColumn();
return $value !== false ? (string)$value : null;
} catch (PDOException $e) {
return null;
}
}
private function setSchemaVersion(PDO $pdo, string $version): void
{
$stmt = $pdo->prepare('INSERT OR REPLACE INTO flex_meta (meta_key, meta_value) VALUES (?, ?)');
$stmt->execute(['schema_version', $version]);
}
private function doUpsert(PDO $pdo, string $key, array $meta): void
{
// Build column list: core + type-specific
$allColumns = ['storage_key', 'storage_timestamp', 'checksum', 'key'];
foreach ($this->columns as $col) {
$allColumns[] = $col['name'];
}
$placeholders = implode(', ', array_fill(0, count($allColumns), '?'));
$columnList = implode(', ', $allColumns);
$stmt = $pdo->prepare("INSERT OR REPLACE INTO flex_index ({$columnList}) VALUES ({$placeholders})");
// Build values: core first, then type-specific in order
$values = [
$key,
(int)($meta['storage_timestamp'] ?? 0),
$meta['checksum'] ?? null,
(string)($meta['key'] ?? $key),
];
foreach ($this->columns as $col) {
$name = $col['name'];
$type = strtoupper($col['type']);
$raw = $meta[$name] ?? null;
if ($raw === null) {
$values[] = null;
} elseif (strpos($type, 'INTEGER') !== false) {
$values[] = (int)$raw;
} else {
$values[] = (string)$raw;
}
}
$stmt->execute($values);
}
/**
* Convert a SQLite row back to the meta array format expected by FlexIndex.
*/
private function rowToMeta(array $row): array
{
$meta = [
'storage_key' => $row['storage_key'],
'storage_timestamp' => (int)$row['storage_timestamp'],
'checksum' => $row['checksum'],
'key' => $row['key'],
];
foreach ($this->columns as $col) {
$name = $col['name'];
$type = strtoupper($col['type']);
$raw = $row[$name] ?? null;
if ($raw === null) {
$meta[$name] = null;
} elseif (strpos($type, 'INTEGER') !== false) {
// Check if this is a boolean-like column (name contains 'is_')
if (strpos($name, 'is_') === 0) {
$meta[$name] = (bool)$raw;
} else {
$meta[$name] = (int)$raw;
}
} else {
$meta[$name] = (string)$raw;
}
}
return $meta;
}
}
@@ -0,0 +1,410 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Table;
use Grav\Common\Debugger;
use Grav\Common\Grav;
use Grav\Framework\Collection\CollectionInterface;
use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface;
use Grav\Framework\Flex\Interfaces\FlexCollectionInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use JsonSerializable;
use Throwable;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use function is_array;
use function is_string;
/**
* Class DataTable
* @package Grav\Plugin\Gitea
*
* https://github.com/ratiw/vuetable-2/wiki/Data-Format-(JSON)
* https://github.com/ratiw/vuetable-2/wiki/Sorting
*/
class DataTable implements JsonSerializable
{
/** @var string */
private $url;
/** @var int */
private $limit;
/** @var int */
private $page;
/** @var array */
private $sort;
/** @var string */
private $search;
/** @var FlexCollectionInterface */
private $collection;
/** @var FlexCollectionInterface */
private $filteredCollection;
/** @var array */
private $columns;
/** @var Environment */
private $twig;
/** @var array */
private $twig_context;
/**
* DataTable constructor.
* @param array $params
*/
public function __construct(array $params)
{
$this->setUrl($params['url'] ?? '');
$this->setLimit((int)($params['limit'] ?? 10));
$this->setPage((int)($params['page'] ?? 1));
$this->setSort($params['sort'] ?? ['id' => 'asc']);
$this->setSearch($params['search'] ?? '');
}
/**
* @param string $url
* @return void
*/
public function setUrl(string $url): void
{
$this->url = $url;
}
/**
* @param int $limit
* @return void
*/
public function setLimit(int $limit): void
{
$this->limit = max(1, $limit);
}
/**
* @param int $page
* @return void
*/
public function setPage(int $page): void
{
$this->page = max(1, $page);
}
/**
* @param string|string[] $sort
* @return void
*/
public function setSort($sort): void
{
if (is_string($sort)) {
$sort = $this->decodeSort($sort);
} elseif (!is_array($sort)) {
$sort = [];
}
$this->sort = $sort;
}
/**
* @param string $search
* @return void
*/
public function setSearch(string $search): void
{
$this->search = $search;
}
/**
* @param CollectionInterface $collection
* @return void
*/
public function setCollection(CollectionInterface $collection): void
{
$this->collection = $collection;
$this->filteredCollection = null;
}
/**
* @return int
*/
public function getLimit(): int
{
return $this->limit;
}
/**
* @return int
*/
public function getPage(): int
{
return $this->page;
}
/**
* @return int
*/
public function getLastPage(): int
{
return 1 + (int)floor(max(0, $this->getTotal()-1) / $this->getLimit());
}
/**
* @return int
*/
public function getTotal(): int
{
$collection = $this->filteredCollection ?? $this->getCollection();
return $collection ? $collection->count() : 0;
}
/**
* @return array
*/
public function getSort(): array
{
return $this->sort;
}
/**
* @return FlexCollectionInterface|null
*/
public function getCollection(): ?FlexCollectionInterface
{
return $this->collection;
}
/**
* @param int $page
* @return string|null
*/
public function getUrl(int $page): ?string
{
if ($page < 1 || $page > $this->getLastPage()) {
return null;
}
return "{$this->url}.json?page={$page}&per_page={$this->getLimit()}&sort={$this->encodeSort()}";
}
/**
* @return array
*/
public function getColumns(): array
{
if (null === $this->columns) {
$collection = $this->getCollection();
if (!$collection) {
return [];
}
$blueprint = $collection->getFlexDirectory()->getBlueprint();
$schema = $blueprint->schema();
$columns = $blueprint->get('config/admin/views/list/fields') ?? $blueprint->get('config/admin/list/fields', []);
$list = [];
foreach ($columns as $key => $options) {
if (!isset($options['field'])) {
$options['field'] = $schema->get($options['alias'] ?? $key);
}
if (!$options['field'] || !empty($options['field']['ignore'])) {
continue;
}
$list[$key] = $options;
}
$this->columns = $list;
}
return $this->columns;
}
/**
* @return array
*/
public function getData(): array
{
$grav = Grav::instance();
/** @var Debugger $debugger */
$debugger = $grav['debugger'];
$debugger->startTimer('datatable', 'Data Table');
$collection = $this->getCollection();
if (!$collection) {
return [];
}
if ($this->search !== '') {
$collection = $collection->search($this->search);
}
$columns = $this->getColumns();
$collection = $collection->sort($this->getSort());
$this->filteredCollection = $collection;
$limit = $this->getLimit();
$page = $this->getPage();
$to = $page * $limit;
$from = $to - $limit + 1;
if ($from < 1 || $from > $this->getTotal()) {
$debugger->stopTimer('datatable');
return [];
}
$array = $collection->slice($from-1, $limit);
$twig = $grav['twig'];
$grav->fireEvent('onTwigSiteVariables');
$this->twig = $twig->twig;
$this->twig_context = $twig->twig_vars;
$list = [];
/** @var FlexObjectInterface $object */
foreach ($array as $object) {
$item = [
'id' => $object->getKey(),
'timestamp' => $object->getTimestamp()
];
foreach ($columns as $name => $column) {
$item[str_replace('.', '_', $name)] = $this->renderColumn($name, $column, $object);
}
$item['_actions_'] = $this->renderActions($object);
$list[] = $item;
}
$debugger->stopTimer('datatable');
return $list;
}
/**
* @return array
*/
public function jsonSerialize(): array
{
$data = $this->getData();
$total = $this->getTotal();
$limit = $this->getLimit();
$page = $this->getPage();
$to = $page * $limit;
$from = $to - $limit + 1;
$empty = empty($data);
return [
'links' => [
'pagination' => [
'total' => $total,
'per_page' => $limit,
'current_page' => $page,
'last_page' => $this->getLastPage(),
'next_page_url' => $this->getUrl($page+1),
'prev_page_url' => $this->getUrl($page-1),
'from' => $empty ? null : $from,
'to' => $empty ? null : min($to, $total),
]
],
'data' => $data
];
}
/**
* @param string $name
* @param array $column
* @param FlexObjectInterface $object
* @return false|string
* @throws Throwable
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
protected function renderColumn(string $name, array $column, FlexObjectInterface $object)
{
$grav = Grav::instance();
$flex = $grav['flex_objects'];
$value = $object->getFormValue($name) ?? $object->getNestedProperty($name, $column['field']['default'] ?? null);
$type = $column['field']['type'] ?? 'text';
$hasLink = $column['link'] ?? null;
$link = null;
$authorized = $object instanceof FlexAuthorizeInterface
? ($object->isAuthorized('read') || $object->isAuthorized('update')) : true;
if ($hasLink && $authorized) {
$route = $grav['route']->withExtension('');
$link = $route->withAddedPath($object->getKey())->withoutParams()->getUri();
}
$template = $this->twig->resolveTemplate(["forms/fields/{$type}/edit_list.html.twig", 'forms/fields/text/edit_list.html.twig']);
return $this->twig->load($template)->render([
'value' => $value,
'link' => $link,
'field' => $column['field'],
'object' => $object,
'flex' => $flex,
'route' => $grav['route']->withExtension('')
] + $this->twig_context);
}
/**
* @param FlexObjectInterface $object
* @return false|string
* @throws Throwable
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
protected function renderActions(FlexObjectInterface $object)
{
$grav = Grav::instance();
$type = $object->getFlexType();
$template = $this->twig->resolveTemplate(["flex-objects/types/{$type}/list/list_actions.html.twig", 'flex-objects/types/default/list/list_actions.html.twig']);
return $this->twig->load($template)->render([
'object' => $object,
'flex' => $grav['flex_objects'],
'route' => $grav['route']->withExtension('')
] + $this->twig_context);
}
/**
* @param string $sort
* @param string $fieldSeparator
* @param string $orderSeparator
* @return array
*/
protected function decodeSort(string $sort, string $fieldSeparator = ',', string $orderSeparator = '|'): array
{
$strings = explode($fieldSeparator, $sort);
$list = [];
foreach ($strings as $string) {
$item = explode($orderSeparator, $string, 2);
$key = array_shift($item);
$order = array_shift($item) === 'desc' ? 'desc' : 'asc';
$list[$key] = $order;
}
return $list;
}
/**
* @param string $fieldSeparator
* @param string $orderSeparator
* @return string
*/
protected function encodeSort(string $fieldSeparator = ',', string $orderSeparator = '|'): string
{
$list = [];
foreach ($this->getSort() as $key => $order) {
$list[] = $key . $orderSeparator . ($order ?: 'asc');
}
return implode($fieldSeparator, $list);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Grav\Plugin\Shortcodes;
use Grav\Framework\Flex\Interfaces\FlexCollectionInterface;
use Grav\Framework\Flex\Interfaces\FlexInterface;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
/**
* [flex-objects] shortcode — render a Flex collection inline in page content.
*
* A sandbox-safe replacement for putting raw Twig in content, e.g.
* {% render grav.get('flex').collection('people').select([...]) %}
* which the Grav 2.0 Twig sandbox blocks by default. The shortcode handler
* runs server-side with full privileges, so editors only ever type the safe,
* limited shortcode syntax while the actual Flex render happens in PHP.
*
* Usage:
* [flex-objects collection=people /]
* [flex-objects collection=people select=a131e8aa65,d46e15eaf5,987691a5c3 /]
* [flex-objects collection=people layout=cards limit=10 sort="last_name|asc" /]
*
* The collection is rendered through its Flex template
* (flex/{collection}/collection/{layout}.html.twig), exactly as `{% render %}`
* would, so existing collection layouts keep working.
*/
class FlexObjectsShortcode extends Shortcode
{
/**
* {@inheritdoc}
*/
public function init()
{
$handler = function (ShortcodeInterface $sc) {
return $this->renderCollection($sc);
};
// `[flex-objects ...]` is the canonical tag; `[flex ...]` is a shorter alias.
$this->shortcode->getHandlers()->add('flex-objects', $handler);
$this->shortcode->getHandlers()->add('flex', $handler);
}
/**
* @param ShortcodeInterface $sc
* @return string
*/
protected function renderCollection(ShortcodeInterface $sc): string
{
// Accept `collection=`, `type=`, or the bbcode form [flex-objects=people].
$type = $sc->getParameter('collection')
?? $sc->getParameter('type')
?? $this->getBbCode($sc);
$type = is_string($type) ? trim($type) : '';
if ($type === '') {
return '';
}
/** @var FlexInterface|null $flex */
$flex = $this->grav['flex'] ?? null;
$collection = $flex ? $flex->getCollection($type) : null;
if (!$collection instanceof FlexCollectionInterface) {
return '';
}
// select=key1,key2,... — narrow to these objects, preserving their order.
$select = $sc->getParameter('select');
if (is_string($select) && $select !== '') {
$keys = array_values(array_filter(array_map('trim', explode(',', $select)), 'strlen'));
if ($keys) {
$collection = $collection->select($keys);
}
}
// sort=field or sort="field|asc" / sort="field|desc"
$sort = $sc->getParameter('sort') ?? $sc->getParameter('order');
if (is_string($sort) && $sort !== '') {
[$field, $dir] = array_pad(explode('|', $sort, 2), 2, 'asc');
$field = trim($field);
if ($field !== '') {
$dir = strtoupper(trim($dir)) === 'DESC' ? 'DESC' : 'ASC';
$collection = $collection->sort([$field => $dir]);
}
}
// limit=N — first N objects.
$limit = $sc->getParameter('limit');
if (is_numeric($limit) && (int) $limit > 0) {
$collection = $collection->limit(0, (int) $limit);
}
// layout selects the collection template; null falls back to 'default'.
$layout = $sc->getParameter('layout');
$layout = is_string($layout) && $layout !== '' ? $layout : null;
return (string) $collection->render($layout);
}
}