feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user