diff --git a/application/controllers/AdminPermissions.php b/application/controllers/AdminPermissions.php index 3bb1b750..2a9ff501 100644 --- a/application/controllers/AdminPermissions.php +++ b/application/controllers/AdminPermissions.php @@ -12,43 +12,15 @@ * pure json api for new ui */ class AdminPermissions extends Instance_Controller { - // mirrors the structure of AuthHelpers::$authTypes, - // @see UMNHelper:$authTypes for an example - const GLOBAL_GROUP_TYPES = [ - ALL_TYPE => [ - "name" => ALL_TYPE, - "label" => "All", - "helpText" => "Everyone, including signed-out visitors.", - // vestigial group_value - "ignoresGroupValues" => true, - ], - AUTHED_TYPE => [ - "name" => AUTHED_TYPE, - "label" => "Authenticated Users", - "helpText" => "Anyone signed in, by any login method.", - "ignoresGroupValues" => true, - ], - REMOTE_TYPE => [ - "name" => REMOTE_TYPE, - "label" => "Centrally Authenticated Users", - "helpText" => "Users signed in through central " - . "single sign-on (SSO).", - "ignoresGroupValues" => true, - ], - USER_TYPE => [ - "name" => USER_TYPE, - "label" => "Specific People", - "helpText" => "Specific people you choose. Add by name, email, or username.", - ], - ]; - private AuthHelper $authHelper; + private GroupTypeCatalog $groupTypeCatalog; private EntityManager $em; public function __construct() { parent::__construct(); $this->load->library('SimpleValidator'); $this->authHelper = $this->user_model->getAuthHelper(); + $this->groupTypeCatalog = new GroupTypeCatalog($this->authHelper->authTypes); $this->em = $this->doctrine->em; } @@ -62,8 +34,9 @@ public function groupTypes() { "label" => $t["label"], "description" => $t["helpText"] ?? "", "entryHints" => $this->entryHintsForType($t["name"]), + "adminOnly" => $this->groupTypeCatalog->isAdminOnly($t["name"]), ], - array_values($this->getGroupTypes()) + array_values($this->groupTypeCatalog->all()) ); return render_json(["groupTypes" => $groupTypes]); @@ -83,7 +56,7 @@ private function entryHintsForType(string $type): array { // Global types never take entries. Guard by type category rather // than trusting that no auth helper ever keys its userData by a // global type name, since helpers pick their keys independently. - if (!$this->isAuthHelperGroupType($type)) { + if (!$this->groupTypeCatalog->isAuthHelperType($type)) { return []; } @@ -291,7 +264,7 @@ private function updateGroup(int $groupId) { foreach ($group->getGroupValues()->toArray() as $entry) { $group->removeGroupValue($entry); // orphanRemoval deletes on flush } - $group->setGroupValue($this->ignoresGroupValues($newType) ? 1 : null); + $group->setGroupValue($this->groupTypeCatalog->ignoresGroupValues($newType) ? 1 : null); } $this->doctrine->em->flush(); @@ -486,19 +459,12 @@ private function memberPayload(Entity\User $user): array { ]; } - private function getGroupTypes(): array { - return [ - ...self::GLOBAL_GROUP_TYPES, - ...$this->authHelper->authTypes, - ]; - } - /** * Shared validation rules for a group's editable attributes (label + - * type). createGroup adds its own `values` rule on top of these. + * type), used by both createGroup and updateGroup. */ private function groupAttributeRules(): array { - $validTypes = array_keys($this->getGroupTypes()); + $validTypes = array_keys($this->groupTypeCatalog->all()); return [ 'type' => [ V::required(), @@ -537,35 +503,11 @@ private function createGroup() { $group->setGroupType($type); $group->setGroupLabel($validated['label']); - if ($this->ignoresGroupValues($type)) { - // vestigial scalar, must be 1: Authed/Authed_remote match on it + if ($this->groupTypeCatalog->ignoresGroupValues($type)) { + // must be 1 for legacy reasons $group->setGroupValue(1); } else { $group->setGroupValue(null); - - $nonEmptyValues = array_filter( - (array) ($validated['values'] ?? []), - fn($v) => $v !== '' - ); - - // create a GroupEntry for each value - foreach ($nonEmptyValues as $value) { - // a non-numeric User value is a remote auth-system id; resolve - // it to a local user id. other types store their value as-is - if ($type === USER_TYPE && !is_numeric($value)) { - // a non-numeric value is a remote username; provision its - // local row and store the resulting user id - try { - $value = $this->firstOrProvisionRemoteUser($value)->getId(); - } catch (RemoteUserNotFoundException $e) { - return abort_json(['error' => $e->getMessage()], 404); - } - } - - $entry = new Entity\GroupEntry(); - $entry->setGroupValue($value); - $group->addGroupValue($entry); - } } $this->doctrine->em->persist($group); @@ -576,66 +518,6 @@ private function createGroup() { return render_json(['group' => $group], 201); } - /** - * Whether `$type` comes from the instance's AuthHelper rather than the - * built-in GLOBAL_GROUP_TYPES. Auth-helper groups match users on their - * value entries; the built-ins never do (User matches member ids, the - * rest match whole populations). - */ - private function isAuthHelperGroupType(string $type): bool { - return !isset(self::GLOBAL_GROUP_TYPES[$type]); - } - - /** - * Whether `$type` matches a whole population instead of a values - * list (All/Authed/Authed_remote). - */ - private function ignoresGroupValues(string $type): bool { - // auth-helper types always carry values, so absence means false - return self::GLOBAL_GROUP_TYPES[$type]['ignoresGroupValues'] ?? false; - } - - /** - * Find a remote user within the local DB by their remote id - * (e.g. username, umndid). If not found, creates a new - * user in the local DB with the remoteUserId set. - * - * @throws RemoteUserNotFoundException if the user cannot be found or - * provisioned. - * @return Entity\User the user record matching the remote id - */ - private function firstOrProvisionRemoteUser(string $remoteUserId): Entity\User { - // findById($id, true) will make new (unsaved) an Entity\User record with - // the given remote id if nothing is found. - /** @var ?Entity\User $remoteUser */ - $remoteUser = $this->authHelper->findById($remoteUserId, true)[0] ?? null; - - if ($remoteUser === null) { - throw new RemoteUserNotFoundException($remoteUserId); - } - - // does a user already exist in the local DB with this username? - /** @var ?Entity\User $existingUser */ - $existingUser = $this->doctrine->em->getRepository(Entity\User::class) - ->findOneBy(["username" => $remoteUser->getUsername()]); - - // if so, return it instead of the new unsaved one - if ($existingUser !== null) { - return $existingUser; - } - - // otherwise, fill in some blanks in the new record - $remoteUser->setUserType("Remote"); - $remoteUser->setCreatedAt(new \DateTime("now")); - $remoteUser->setInstance($this->instance); - - // and then save it - $this->doctrine->em->persist($remoteUser); - $this->doctrine->em->flush(); - - return $remoteUser; - } - /** * GET /adminPermissions/groups/{id}/entries: a group's raw match values. */ @@ -648,7 +530,7 @@ private function listGroupEntries(int $groupId): CI_Output { return abort_json(['error' => 'Group not found'], 404); } - if (!$this->isAuthHelperGroupType($group->getGroupType())) { + if (!$this->groupTypeCatalog->isAuthHelperType($group->getGroupType())) { return abort_json( ['error' => 'Only auth-helper group types take entries'], 422 @@ -672,7 +554,7 @@ private function addGroupEntry(int $groupId): CI_Output { return abort_json(['error' => 'Group not found'], 404); } - if (!$this->isAuthHelperGroupType($group->getGroupType())) { + if (!$this->groupTypeCatalog->isAuthHelperType($group->getGroupType())) { return abort_json( ['error' => 'Only auth-helper group types take entries'], 422 @@ -710,7 +592,7 @@ private function updateGroupEntry(int $groupId, int $entryId): CI_Output { return abort_json(['error' => 'Group not found'], 404); } - if (!$this->isAuthHelperGroupType($group->getGroupType())) { + if (!$this->groupTypeCatalog->isAuthHelperType($group->getGroupType())) { return abort_json( ['error' => 'Only auth-helper group types take entries'], 422 @@ -751,7 +633,7 @@ private function removeGroupEntry(int $groupId, int $entryId): CI_Output { return abort_json(['error' => 'Group not found'], 404); } - if (!$this->isAuthHelperGroupType($group->getGroupType())) { + if (!$this->groupTypeCatalog->isAuthHelperType($group->getGroupType())) { return abort_json( ['error' => 'Only auth-helper group types take entries'], 422 diff --git a/application/controllers/DrawerPermissions.php b/application/controllers/DrawerPermissions.php new file mode 100644 index 00000000..c8834c90 --- /dev/null +++ b/application/controllers/DrawerPermissions.php @@ -0,0 +1,426 @@ +load->library('SimpleValidator'); + $this->groupTypeCatalog = new GroupTypeCatalog( + $this->user_model->getAuthHelper()->authTypes + ); + $this->em = $this->doctrine->em; + } + + /** + * GET /drawerPermissions/groupTypes: the catalog for the type selector. + * Admin-only types stay in the list so non-admin UIs can show them + * disabled, matching the legacy form. + */ + public function groupTypes(): CI_Output { + $this->abortUnlessCanManageDrawers(); + + $groupTypes = array_map( + fn($type) => [ + "type" => $type["name"], + "label" => $type["label"], + "description" => $type["helpText"] ?? "", + // TODO: real hints, see AdminPermissions::entryHintsForType + "entryHints" => [], + "adminOnly" => $this->groupTypeCatalog->isAdminOnly($type["name"]), + ], + array_values($this->groupTypeCatalog->all()) + ); + + return render_json(["groupTypes" => $groupTypes]); + } + + /** + * GET /drawerPermissions/drawers: drawers the signed-in user can + * manage, for the management page's Drawers tab. + */ + public function drawers(): CI_Output { + $this->abortUnlessCanManageDrawers(); + + $drawerPayload = array_map( + fn(Drawer $drawer) => [ + 'id' => $drawer->getId(), + 'title' => $drawer->getTitle(), + ], + $this->manageableDrawers() + ); + + return render_json(['drawers' => $drawerPayload]); + } + + /** + * REST entry point for /drawerPermissions/groups[/{id}]. + * + * Trailing URL segments arrive as method args, so a request to + * /drawerPermissions/groups/5 calls this with $groupId = "5". + */ + public function groups($groupId = null): CI_Output { + $this->abortUnlessCanManageDrawers(); + + $method = $this->input->server('REQUEST_METHOD'); + + $groupId = $groupId === null + ? null + : filter_var($groupId, FILTER_VALIDATE_INT); + + // a non-numeric id segment becomes false + if ($groupId === false) { + return abort_json(['error' => 'Invalid ID'], 400); + } + + $route = '/groups'; + if ($groupId !== null) { + $route .= '/{id}'; + } + + $table = [ + '/groups' => [ + 'GET' => fn() => $this->listGroups(), + 'POST' => fn() => $this->createGroup(), + ], + '/groups/{id}' => [ + 'GET' => fn() => $this->showGroup($groupId), + 'PUT' => fn() => $this->updateGroup($groupId), + 'PATCH' => fn() => $this->updateGroup($groupId), + 'DELETE' => fn() => $this->deleteGroup($groupId), + ], + ]; + + if (!isset($table[$route])) { + return abort_json(['error' => 'Not Found'], 404); + } + + $handler = $table[$route][$method] ?? null; + + if ($handler) { + return $handler(); + } + + return abort_json(['error' => 'Method Not Allowed'], 405); + } + + private function listGroups(): CI_Output { + $personalGroupId = $this->getPersonalDrawerGroup()?->getId(); + + $groups = $this->em + ->getRepository(DrawerGroup::class) + ->findBy(['user' => $this->user_model->user]); + + + $withPersonalFlag = fn(DrawerGroup $group): array => + $group->jsonSerialize() + ['is_personal' => $group->getId() === $personalGroupId]; + + return render_json(['groups' => array_map($withPersonalFlag, $groups)]); + } + + /** + * GET /drawerPermissions/groups/{id}: one of the user's own groups. + */ + private function showGroup(int $groupId): CI_Output { + $this->abortIfPersonalGroup($groupId); + + $group = $this->findCurrentUserDrawerGroup($groupId); + if (!$group) { + return abort_json(['error' => 'Group not found'], 404); + } + + return render_json(['group' => $group]); + } + + /** + * POST /drawerPermissions/groups: create an empty group owned by the + * signed-in user. Members and match values are added separately. + */ + private function createGroup(): CI_Output { + try { + $validated = V::validate( + $this->requestBody(), + $this->groupAttributeRules() + ); + } catch (ValidationException $e) { + return abort_json(['errors' => $e->getErrors()], 422); + } + + $type = $validated['type']; + if (!$this->canUseGroupType($type)) { + return abort_json( + ['error' => 'Only instance admins can use instance-wide group types'], + 403 + ); + } + + $group = new DrawerGroup(); + $group->setUser($this->user_model->user); + $group->setGroupType($type); + $group->setGroupLabel($validated['label']); + + if ($this->groupTypeCatalog->ignoresGroupValues($type)) { + // must be 1 for legacy reasons + $group->setGroupValue(1); + } else { + $group->setGroupValue(null); + } + + $this->em->persist($group); + $this->em->flush(); + + $this->removeCurrentUserCache(); + + return render_json(['group' => $group], 201); + } + + /** + * PUT|PATCH /drawerPermissions/groups/{id}: edit a group's label and + * type. + * + * Changing the type clears existing entries, since they belong to the + * old type. Editing only the label leaves them alone. + */ + private function updateGroup(int $groupId): CI_Output { + $this->abortIfPersonalGroup($groupId); + + $group = $this->findCurrentUserDrawerGroup($groupId); + if (!$group) { + return abort_json(['error' => 'Group not found'], 404); + } + + try { + $validated = V::validate( + $this->requestBody(), + $this->groupAttributeRules() + ); + } catch (ValidationException $e) { + return abort_json(['errors' => $e->getErrors()], 422); + } + + $newType = $validated['type']; + $hasTypeChanged = $newType !== $group->getGroupType(); + + // a non-admin may keep an admin-only type it already has (rename + // only), but may not switch a group onto one + if ($hasTypeChanged && !$this->canUseGroupType($newType)) { + return abort_json( + ['error' => 'Only instance admins can use instance-wide group types'], + 403 + ); + } + + $group->setGroupLabel($validated['label']); + + if ($hasTypeChanged) { + $group->setGroupType($newType); + + // toArray() copies first so removing entries does not mutate the + // list mid-loop + foreach ($group->getGroupValues()->toArray() as $entry) { + $group->removeGroupValue($entry); + } + $group->setGroupValue($this->groupTypeCatalog->ignoresGroupValues($newType) ? 1 : null); + } + + $this->em->flush(); + + if ($hasTypeChanged) { + // clearing the members revokes their drawer access, so their + // cached permissions are stale too, not just the owner's + $this->clearAllUserCache(); + } else { + $this->removeCurrentUserCache(); + } + + return render_json(['group' => $group]); + } + + /** + * DELETE /drawerPermissions/groups/{id}: remove one of the user's groups. + */ + private function deleteGroup(int $groupId): CI_Output { + $this->abortIfPersonalGroup($groupId); + + $group = $this->findCurrentUserDrawerGroup($groupId); + if (!$group) { + return abort_json(['error' => 'Group not found'], 404); + } + + $this->em->remove($group); + $this->em->flush(); + + // deleting a group cascades to its members and entries, changing + // many users' permissions, so clear every user's cache + $this->clearAllUserCache(); + + return render_json(['deleted' => $groupId]); + } + + private function abortIfPersonalGroup(int $groupId): void { + if ($groupId === $this->getPersonalDrawerGroup()?->getId()) { + abort_json(['error' => 'Group not found'], 404); + } + } + + /** + * The auto-created "personal" drawer group: a User-type group that + * includes the owner's own id as a member entry, created in Drawers.php + * the first time a user makes a drawer. It backs the user's access to + * their own drawers, so the groups API hides it and refuses to mutate it. + * + * Returns null when the user has never created a drawer, so no personal + * group exists yet. + */ + private function getPersonalDrawerGroup(): ?DrawerGroup { + $ownUserId = $this->user_model?->userId; + + if (!$ownUserId) { + return null; + } + + // heuristic: the oldest User-type group the user owns that lists + // the user's own id as a member + $query = $this->em + ->getRepository(DrawerGroup::class) + ->createQueryBuilder('drawerGroup') + ->join('drawerGroup.group_values', 'entry') + ->where('drawerGroup.user = :ownUserId') + ->andWhere('drawerGroup.group_type = :groupType') + ->andWhere('entry.groupValue = :ownUserId') + ->setParameter('ownUserId', $ownUserId) + ->setParameter('groupType', USER_TYPE) + ->orderBy('drawerGroup.id', 'ASC') + ->setMaxResults(1) + ->getQuery(); + + return $query->getOneOrNullResult(); + } + + private function findCurrentUserDrawerGroup(int $groupId): ?DrawerGroup { + return $this->em + ->getRepository(DrawerGroup::class) + ->findOneBy([ + 'id' => $groupId, + 'user' => $this->user_model->user, + ]); + } + + /** + * Validation rules for a group's editable attributes (label + type). + * The label rejects `< > "` to prevent HTML injection while still + * allowing names like `R&D` and `Bob's Team`. + */ + private function groupAttributeRules(): array { + $validTypes = array_keys($this->groupTypeCatalog->all()); + return [ + 'type' => [ + V::required(), + fn($value) => !isset($value) || in_array($value, $validTypes, true) + ? true + : 'Unknown group type', + ], + 'label' => [ + V::required(), + V::maxLength(255), + V::notRegex('/[<>"]/', 'Label cannot contain < > or " characters') + ], + ]; + } + + /** + * Whether the signed-in user may put a group on `$type`: + * population-wide types are reserved for instance admins. + */ + private function canUseGroupType(string $type): bool { + if (!$this->groupTypeCatalog->isAdminOnly($type)) { + return true; + } + return $this->user_model->isInstanceAdmin() + || $this->user_model->getIsSuperAdmin(); + } + + /** + * Drawers the signed-in user holds at least PERM_CREATEDRAWERS on. + * + * Instance admins hold PERM_ADMIN on every drawer in their instance + * through a fallback in getAccessLevel, not through the + * drawerPermissions map, so they get the whole instance's drawers here. + */ + private function manageableDrawers(): array { + $drawerRepository = $this->em->getRepository(Drawer::class); + + $isEveryDrawerManageable = $this->user_model->isInstanceAdmin() + || $this->user_model->getIsSuperAdmin(); + if ($isEveryDrawerManageable) { + return $drawerRepository->findBy( + ['instance' => $this->instance], + ['title' => 'ASC'] + ); + } + + $manageableDrawerIds = array_keys(array_filter( + $this->user_model->drawerPermissions, + fn($level) => $level >= PERM_CREATEDRAWERS + )); + if (count($manageableDrawerIds) === 0) { + return []; + } + + return $drawerRepository->findBy( + ['id' => $manageableDrawerIds, 'instance' => $this->instance], + ['title' => 'ASC'] + ); + } + + /** + * Abort unless the signed-in user can manage at least one drawer. + */ + private function abortUnlessCanManageDrawers(): void { + $this->abortUnlessAuthed(); + if (!$this->canManageDrawers()) { + abort_json(['error' => 'Forbidden'], 403); + } + } + + private function canManageDrawers(): bool { + if ($this->user_model->getIsSuperAdmin()) { + return true; + } + if ($this->user_model->getAccessLevel('instance', $this->instance) >= PERM_CREATEDRAWERS) { + return true; + } + // getMaxCollectionPermission returns null when the user has no + // collection grants at all + if (($this->user_model->getMaxCollectionPermission() ?? 0) >= PERM_CREATEDRAWERS) { + return true; + } + // max() errors on an empty array, so seed a 0 + $drawerGrantLevels = array_values($this->user_model->drawerPermissions); + return max([0, ...$drawerGrantLevels]) >= PERM_CREATEDRAWERS; + } + + private function removeCurrentUserCache(): void { + if (!$this->config->item('enableCaching')) return; + $this->userCache->delete($this->user_model->userId); + } + + private function clearAllUserCache(): void { + if (!$this->config->item('enableCaching')) return; + $this->userCache->clear(); + } +} diff --git a/application/core/Instance_Controller.php b/application/core/Instance_Controller.php index 6299abc4..41666a14 100644 --- a/application/core/Instance_Controller.php +++ b/application/core/Instance_Controller.php @@ -207,4 +207,43 @@ protected function requestBody(): array { ? $this->input->post() : $this->input->input_stream()) ?? []; } + + /** + * Find a remote user within the local DB by their remote id + * (e.g. username, umndid). If not found, creates a new + * user in the local DB with the remoteUserId set. + * + * @throws RemoteUserNotFoundException if the user cannot be found or + * provisioned. + * @return Entity\User the user record matching the remote id + */ + protected function firstOrProvisionRemoteUser(string $remoteUserId): Entity\User { + // findById($id, true) builds a new unsaved Entity\User with the + // given remote id when nothing is found. + /** @var ?Entity\User $remoteUser */ + $remoteUser = $this->user_model->getAuthHelper() + ->findById($remoteUserId, true)[0] ?? null; + + if ($remoteUser === null) { + throw new RemoteUserNotFoundException($remoteUserId); + } + + /** @var ?Entity\User $existingUser */ + $existingUser = $this->doctrine->em->getRepository(Entity\User::class) + ->findOneBy(["username" => $remoteUser->getUsername()]); + + // prefer an existing local user over the new unsaved record + if ($existingUser !== null) { + return $existingUser; + } + + $remoteUser->setUserType("Remote"); + $remoteUser->setCreatedAt(new \DateTime("now")); + $remoteUser->setInstance($this->instance); + + $this->doctrine->em->persist($remoteUser); + $this->doctrine->em->flush(); + + return $remoteUser; + } } diff --git a/application/libraries/GroupTypeCatalog.php b/application/libraries/GroupTypeCatalog.php new file mode 100644 index 00000000..c9541ba4 --- /dev/null +++ b/application/libraries/GroupTypeCatalog.php @@ -0,0 +1,86 @@ + [ + "name" => ALL_TYPE, + "label" => "All", + "helpText" => "Everyone, including signed-out visitors.", + "populationWide" => true, + ], + AUTHED_TYPE => [ + "name" => AUTHED_TYPE, + "label" => "Authenticated Users", + "helpText" => "Anyone signed in, by any login method.", + "populationWide" => true, + ], + REMOTE_TYPE => [ + "name" => REMOTE_TYPE, + "label" => "Centrally Authenticated Users", + "helpText" => "Users signed in through central " + . "single sign-on (SSO).", + "populationWide" => true, + ], + USER_TYPE => [ + "name" => USER_TYPE, + "label" => "Specific People", + "helpText" => "Specific people you choose. Add by name, email, or username.", + ], + ]; + + private array $authHelperTypes; + + /** + * @param array $authHelperTypes the instance auth helper's authTypes, + * keyed by type string (e.g. Unit, JobCode). Empty for helperless + * instances, which then offer only the built-in types. + */ + public function __construct(array $authHelperTypes = []) { + $this->authHelperTypes = $authHelperTypes; + } + + /** + * Every type this instance offers, keyed by type string: the built-in + * types first, then the auth helper's own types. + */ + public function all(): array { + return [ + ...self::BUILTIN_TYPES, + ...$this->authHelperTypes, + ]; + } + + public function isValid(string $type): bool { + return isset($this->all()[$type]); + } + + /** + * Whether $type comes from the auth helper rather than the built-in + * types. Auth-helper groups match users on their value entries. The + * built-ins never do: User matches member ids, the rest whole populations. + */ + public function isAuthHelperType(string $type): bool { + return !isset(self::BUILTIN_TYPES[$type]); + } + + /** + * Whether $type matches a whole population instead of a values list + * (All/Authed/Authed_remote). The group holds no entries. + */ + public function ignoresGroupValues(string $type): bool { + return self::BUILTIN_TYPES[$type]["populationWide"] ?? false; + } + + /** + * Whether only an instance admin may put a group on $type. The + * population-wide types are admin only, every other type is open to any + * group owner. + */ + public function isAdminOnly(string $type): bool { + return $this->ignoresGroupValues($type); + } +} diff --git a/application/libraries/MockAuthHelper.php b/application/libraries/MockAuthHelper.php index 567b817a..f2f8939f 100644 --- a/application/libraries/MockAuthHelper.php +++ b/application/libraries/MockAuthHelper.php @@ -111,7 +111,6 @@ public function createUserFromRemote($userOverride = null, $map = null) { $user->setHasExpiry(false); $user->setCreatedAt(new \DateTime("now")); $user->setInstance($this->CI->instance); - $user->setIsSuperAdmin(true); $user->setFastUpload(false); $this->CI->doctrine->em->persist($user); $this->CI->doctrine->em->flush(); diff --git a/application/libraries/SimpleValidator.php b/application/libraries/SimpleValidator.php index 66fc73ff..d66164eb 100644 --- a/application/libraries/SimpleValidator.php +++ b/application/libraries/SimpleValidator.php @@ -1,8 +1,7 @@ closure[]) * Returns filtered data or throws ValidationException @@ -26,8 +25,7 @@ class SimpleValidator * $errors = $e->getErrors(); * } */ - public static function validate(array $data, array $schema): array - { + public static function validate(array $data, array $schema): array { $errors = []; foreach ($schema as $field => $checkers) { @@ -51,63 +49,77 @@ public static function validate(array $data, array $schema): array // ----- Validators - public static function required(): \Closure - { + public static function required(): \Closure { return fn($v) => (isset($v) && $v !== '') ? true : 'This field is required'; } - public static function integer(): \Closure - { + public static function integer(): \Closure { return fn($v) => !isset($v) || filter_var($v, FILTER_VALIDATE_INT) !== false ? true : 'Must be an integer'; } - public static function array(): \Closure - { + public static function array(): \Closure { return fn($v) => !isset($v) || is_array($v) ? true : 'Must be an array'; } - public static function string(): \Closure - { + public static function string(): \Closure { return fn($v) => !isset($v) || is_string($v) ? true : 'Must be a string'; } - public static function min(int $min): \Closure - { + public static function min(int $min): \Closure { return fn($v) => !isset($v) || (is_numeric($v) && $v >= $min) ? true : "Must be at least {$min}"; } - public static function max(int $max): \Closure - { + public static function max(int $max): \Closure { return fn($v) => !isset($v) || (is_numeric($v) && $v <= $max) ? true : "Must not exceed {$max}"; } - public static function regex(string $pattern): \Closure - { - return fn($v) => !isset($v) || preg_match($pattern, $v) - ? true - : "Invalid format."; + public static function regex(string $pattern, $errorMessage = 'Invalid format.'): \Closure { + return function ($v) use ($pattern, $errorMessage) { + // absent stays valid, use required() to enforce presence + if (!isset($v)) { + return true; + } + // a non-string can't match a pattern, so report it rather than + // letting preg_match throw a TypeError + if (!is_string($v)) { + return $errorMessage; + } + return preg_match($pattern, $v) ? true : $errorMessage; + }; + } + + public static function notRegex(string $pattern, $errorMessage = 'String is invalid format.'): \Closure { + return function ($v) use ($pattern, $errorMessage) { + // absent stays valid, use required() to enforce presence + if (!isset($v)) { + return true; + } + // a non-string has no forbidden content to match, but the wrong + // type is still a failure, so report it rather than let it pass + if (!is_string($v)) { + return $errorMessage; + } + return preg_match($pattern, $v) ? $errorMessage : true; + }; } - public static function minLength(int $length): \Closure - { + public static function minLength(int $length): \Closure { return fn($v) => !isset($v) || (is_string($v) && mb_strlen($v) >= $length) ? true : "Must be at least {$length} characters long"; } - public static function maxLength(int $length): \Closure - { + public static function maxLength(int $length): \Closure { return fn($v) => !isset($v) || (is_string($v) && mb_strlen($v) <= $length) ? true : "Must be {$length} characters or fewer"; } - public static function json(): \Closure - { + public static function json(): \Closure { return function ($v) { if (!isset($v)) return true; if (!is_string($v)) return 'Must be valid JSON'; diff --git a/application/models/Entity/DrawerGroup.php b/application/models/Entity/DrawerGroup.php index 60742b3d..e65ec970 100644 --- a/application/models/Entity/DrawerGroup.php +++ b/application/models/Entity/DrawerGroup.php @@ -9,7 +9,7 @@ */ #[ORM\Table(name: 'drawer_groups')] #[ORM\Entity] -class DrawerGroup +class DrawerGroup implements \JsonSerializable { /** * @var string|null @@ -324,4 +324,14 @@ public function getGroupValues() { return $this->group_values; } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'type' => $this->group_type, + 'label' => $this->group_label, + // n+1 query, but prob fine at our scale + 'entries_count' => $this->group_values->count(), + ]; + } }