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 */ 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; } }