|
|
@@ -0,0 +1,478 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace OpenMind\Api;
|
|
|
+
|
|
|
+use Base;
|
|
|
+use DB\SQL;
|
|
|
+use PDOException;
|
|
|
+
|
|
|
+final class RoleController extends Controller
|
|
|
+{
|
|
|
+ private const ADMINISTRATOR_ID = 1;
|
|
|
+ private const ROLES_TABLE = 'roles';
|
|
|
+ private const USERS_TABLE = 'users';
|
|
|
+ private const STATUS_OK = 200;
|
|
|
+ private const STATUS_CREATED = 201;
|
|
|
+ private const STATUS_BAD_REQUEST = 400;
|
|
|
+ private const STATUS_INTERNAL_SERVER_ERROR = 500;
|
|
|
+ private const STATUS_NOT_FOUND = 404;
|
|
|
+ private const STATUS_CONFLICT = 409;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Lists role metadata for the administrator.
|
|
|
+ *
|
|
|
+ * @return void Writes a JSON response containing safe role DTOs.
|
|
|
+ * @side-effects Reads the roles table and the current session.
|
|
|
+ */
|
|
|
+ public function list(): void
|
|
|
+ {
|
|
|
+ if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->respond(function (): array {
|
|
|
+ $database = $this->database();
|
|
|
+ $roles = $database->exec(
|
|
|
+ 'SELECT id, name, description FROM ' . self::ROLES_TABLE . ' ORDER BY name, id'
|
|
|
+ );
|
|
|
+
|
|
|
+ return ['roles' => array_map($this->roleDto(...), $roles ?: [])];
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Creates a role from the JSON request body.
|
|
|
+ *
|
|
|
+ * @return void Writes a JSON response containing only the created role DTO.
|
|
|
+ * @side-effects Validates CSRF, inserts a role, and reads the inserted row.
|
|
|
+ */
|
|
|
+ public function create(): void
|
|
|
+ {
|
|
|
+ if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$this->validateCsrfToken()) {
|
|
|
+ $this->sendError('Invalid CSRF token', 403);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->respond(function (): array {
|
|
|
+ $input = $this->decodeJsonRequest();
|
|
|
+ if ($input === null) {
|
|
|
+ throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_JSON', 'The request body must be valid JSON.');
|
|
|
+ }
|
|
|
+
|
|
|
+ [$name, $description] = $this->roleAttributes($input);
|
|
|
+ $database = $this->database();
|
|
|
+
|
|
|
+ try {
|
|
|
+ $this->ensureUniqueName($database, $name);
|
|
|
+ $database->exec(
|
|
|
+ 'INSERT INTO ' . self::ROLES_TABLE . ' (name, description) VALUES (?, ?)',
|
|
|
+ [$name, $description]
|
|
|
+ );
|
|
|
+ } catch (PDOException $exception) {
|
|
|
+ $this->throwIfDuplicateName($exception);
|
|
|
+ throw $exception;
|
|
|
+ }
|
|
|
+
|
|
|
+ $roleId = (int) $database->lastInsertId();
|
|
|
+ $role = $this->findRole($database, $roleId);
|
|
|
+ if ($role === null) {
|
|
|
+ throw new RoleApiException(self::STATUS_INTERNAL_SERVER_ERROR, 'ROLE_CREATE_FAILED', 'The role could not be created.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return ['role' => $this->roleDto($role)];
|
|
|
+ }, self::STATUS_CREATED);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Updates a role from the JSON request body.
|
|
|
+ *
|
|
|
+ * @param Base $f3 F3 application instance.
|
|
|
+ * @param array<string, mixed> $args Route arguments containing the role ID.
|
|
|
+ * @return void Writes a JSON response containing only the updated role DTO.
|
|
|
+ * @side-effects Validates CSRF, updates a role, and reads the updated row.
|
|
|
+ */
|
|
|
+ public function update(Base $f3, array $args = []): void
|
|
|
+ {
|
|
|
+ if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$this->validateCsrfToken()) {
|
|
|
+ $this->sendError('Invalid CSRF token', 403);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->respond(function () use ($args): array {
|
|
|
+ $roleId = $this->roleId($args);
|
|
|
+ $input = $this->decodeJsonRequest();
|
|
|
+ if ($roleId === null || $input === null) {
|
|
|
+ throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_REQUEST', 'A valid role ID and JSON request are required.');
|
|
|
+ }
|
|
|
+
|
|
|
+ [$name, $description] = $this->roleAttributes($input);
|
|
|
+ $database = $this->database();
|
|
|
+ $database->begin();
|
|
|
+ try {
|
|
|
+ $this->lockRolesForMutation($database);
|
|
|
+ $this->lockUsersForMutation($database);
|
|
|
+
|
|
|
+ if ($this->findRole($database, $roleId) === null) {
|
|
|
+ throw new RoleApiException(self::STATUS_NOT_FOUND, 'ROLE_NOT_FOUND', 'Role not found.');
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ $this->ensureUniqueName($database, $name, $roleId);
|
|
|
+ $database->exec(
|
|
|
+ 'UPDATE ' . self::ROLES_TABLE . ' SET name = ?, description = ? WHERE id = ?',
|
|
|
+ [$name, $description, $roleId]
|
|
|
+ );
|
|
|
+ } catch (PDOException $exception) {
|
|
|
+ $this->throwIfDuplicateName($exception);
|
|
|
+ throw $exception;
|
|
|
+ }
|
|
|
+
|
|
|
+ $role = $this->findRole($database, $roleId);
|
|
|
+ if ($role === null) {
|
|
|
+ throw new RoleApiException(self::STATUS_INTERNAL_SERVER_ERROR, 'ROLE_UPDATE_FAILED', 'The role could not be updated.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $database->commit();
|
|
|
+ } catch (RoleApiException $exception) {
|
|
|
+ $database->rollback();
|
|
|
+ throw $exception;
|
|
|
+ } catch (\Throwable $exception) {
|
|
|
+ $database->rollback();
|
|
|
+ throw $exception;
|
|
|
+ }
|
|
|
+
|
|
|
+ return ['role' => $this->roleDto($role)];
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Deletes an unassigned role.
|
|
|
+ *
|
|
|
+ * @param Base $f3 F3 application instance.
|
|
|
+ * @param array<string, mixed> $args Route arguments containing the role ID.
|
|
|
+ * @return void Writes a JSON response containing the deleted role ID.
|
|
|
+ * @side-effects Validates CSRF, locks the role and users, and deletes the role.
|
|
|
+ */
|
|
|
+ public function delete(Base $f3, array $args = []): void
|
|
|
+ {
|
|
|
+ if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$this->validateCsrfToken()) {
|
|
|
+ $this->sendError('Invalid CSRF token', 403);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->respond(function () use ($args): array {
|
|
|
+ $roleId = $this->roleId($args);
|
|
|
+ if ($roleId === null) {
|
|
|
+ throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_ROLE_ID', 'A valid role ID is required.');
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($roleId === self::ADMINISTRATOR_ID) {
|
|
|
+ throw $this->deletionConflict();
|
|
|
+ }
|
|
|
+
|
|
|
+ $database = $this->database();
|
|
|
+ $database->begin();
|
|
|
+ try {
|
|
|
+ $this->lockRolesForMutation($database);
|
|
|
+ $this->lockUsersForMutation($database);
|
|
|
+
|
|
|
+ $role = $database->exec(
|
|
|
+ 'SELECT id FROM ' . self::ROLES_TABLE . ' WHERE id = ?',
|
|
|
+ [$roleId]
|
|
|
+ );
|
|
|
+ if (!$role) {
|
|
|
+ throw new RoleApiException(self::STATUS_NOT_FOUND, 'ROLE_NOT_FOUND', 'Role not found.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $assignedUsers = $database->exec(
|
|
|
+ 'SELECT id, roles FROM ' . self::USERS_TABLE . ' WHERE roles IS NOT NULL'
|
|
|
+ );
|
|
|
+ foreach ($assignedUsers ?: [] as $user) {
|
|
|
+ if ($this->hasRoleAssignment($user['roles'] ?? null, $roleId)) {
|
|
|
+ throw $this->deletionConflict();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $database->exec('DELETE FROM ' . self::ROLES_TABLE . ' WHERE id = ?', [$roleId]);
|
|
|
+ $database->commit();
|
|
|
+ } catch (RoleApiException $exception) {
|
|
|
+ $database->rollback();
|
|
|
+ throw $exception;
|
|
|
+ } catch (\Throwable $exception) {
|
|
|
+ $database->rollback();
|
|
|
+ throw $exception;
|
|
|
+ }
|
|
|
+
|
|
|
+ return ['id' => $roleId];
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Executes a controller operation and serializes its result or API error.
|
|
|
+ *
|
|
|
+ * @param callable(): array $operation Controller operation to execute.
|
|
|
+ * @param int $successStatus HTTP status for a successful operation.
|
|
|
+ * @return void Writes a JSON response.
|
|
|
+ * @side-effects Sets the HTTP status and response body.
|
|
|
+ */
|
|
|
+ private function respond(callable $operation, int $successStatus = self::STATUS_OK): void
|
|
|
+ {
|
|
|
+ try {
|
|
|
+ $this->sendJson($operation(), $successStatus);
|
|
|
+ } catch (RoleApiException $exception) {
|
|
|
+ $this->sendJson([
|
|
|
+ 'error' => $exception->getMessage(),
|
|
|
+ 'code' => $exception->errorCode(),
|
|
|
+ ], $exception->status());
|
|
|
+ } catch (\Throwable $exception) {
|
|
|
+ error_log('Role API operation failed: ' . $exception->getMessage());
|
|
|
+ $this->sendError('Internal server error', self::STATUS_INTERNAL_SERVER_ERROR);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Checks whether a serialized user role list contains the requested role ID.
|
|
|
+ *
|
|
|
+ * @param mixed $serializedRoles Serialized roles column value.
|
|
|
+ * @param int $roleId Role ID to find.
|
|
|
+ * @return bool True when the user is assigned to the role.
|
|
|
+ * @side-effects None.
|
|
|
+ */
|
|
|
+ private function hasRoleAssignment(mixed $serializedRoles, int $roleId): bool
|
|
|
+ {
|
|
|
+ if (!is_string($serializedRoles) || $serializedRoles === '') {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $roles = unserialize($serializedRoles, ['allowed_classes' => false]);
|
|
|
+ if (!is_array($roles)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach ($roles as $assignedRoleId) {
|
|
|
+ if ((is_int($assignedRoleId) || (is_string($assignedRoleId) && ctype_digit($assignedRoleId)))
|
|
|
+ && (int) $assignedRoleId === $roleId) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Returns the configured database connection.
|
|
|
+ *
|
|
|
+ * @return SQL Database connection from the F3 hive.
|
|
|
+ * @side-effects Reads the F3 database hive.
|
|
|
+ */
|
|
|
+ private function database(): SQL
|
|
|
+ {
|
|
|
+ $database = \Base::instance()->get('DB');
|
|
|
+ if (!$database instanceof SQL) {
|
|
|
+ throw new RoleApiException(self::STATUS_INTERNAL_SERVER_ERROR, 'DATABASE_UNAVAILABLE', 'The database is unavailable.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return $database;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Validates and normalizes role metadata.
|
|
|
+ *
|
|
|
+ * @param array<string, mixed> $input Decoded request data.
|
|
|
+ * @return array{0: string, 1: string} Normalized name and description.
|
|
|
+ * @side-effects None.
|
|
|
+ */
|
|
|
+ private function roleAttributes(array $input): array
|
|
|
+ {
|
|
|
+ $name = $input['name'] ?? null;
|
|
|
+ if (!is_string($name) || trim($name) === '') {
|
|
|
+ throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_NAME', 'Role name must be a non-empty string.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $description = $input['description'] ?? null;
|
|
|
+ if ($description !== null && !is_string($description)) {
|
|
|
+ throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_DESCRIPTION', 'Role description must be a string or null.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return [trim($name), $description === null ? '' : trim($description)];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Finds a role by ID and selects only public role metadata.
|
|
|
+ *
|
|
|
+ * @param SQL $database Database connection.
|
|
|
+ * @param int $id Role ID.
|
|
|
+ * @return array<string, mixed>|null Role row or null when absent.
|
|
|
+ * @side-effects Reads one row from the roles table.
|
|
|
+ */
|
|
|
+ private function findRole(SQL $database, int $id): ?array
|
|
|
+ {
|
|
|
+ $roles = $database->exec(
|
|
|
+ 'SELECT id, name, description FROM ' . self::ROLES_TABLE . ' WHERE id = ? LIMIT 1',
|
|
|
+ [$id]
|
|
|
+ );
|
|
|
+
|
|
|
+ return $roles[0] ?? null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Rejects a duplicate role name before attempting a write.
|
|
|
+ *
|
|
|
+ * @param SQL $database Database connection.
|
|
|
+ * @param string $name Normalized role name.
|
|
|
+ * @param int|null $excludedId Role ID excluded during updates.
|
|
|
+ * @return void Throws on a duplicate name.
|
|
|
+ * @side-effects Reads the roles table.
|
|
|
+ */
|
|
|
+ private function ensureUniqueName(SQL $database, string $name, ?int $excludedId = null): void
|
|
|
+ {
|
|
|
+ $sql = 'SELECT id FROM ' . self::ROLES_TABLE . ' WHERE name = ?';
|
|
|
+ $parameters = [$name];
|
|
|
+ if ($excludedId !== null) {
|
|
|
+ $sql .= ' AND id <> ?';
|
|
|
+ $parameters[] = $excludedId;
|
|
|
+ }
|
|
|
+ $sql .= ' LIMIT 1';
|
|
|
+
|
|
|
+ if ($database->exec($sql, $parameters)) {
|
|
|
+ throw new RoleApiException(self::STATUS_CONFLICT, 'ROLE_NAME_CONFLICT', 'Role name already exists.');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Converts a database row into the public role DTO.
|
|
|
+ *
|
|
|
+ * @param array<string, mixed> $role Database role row.
|
|
|
+ * @return array{id: int, name: string, description: string} Safe role DTO.
|
|
|
+ * @side-effects None.
|
|
|
+ */
|
|
|
+ private function roleDto(array $role): array
|
|
|
+ {
|
|
|
+ return [
|
|
|
+ 'id' => (int) $role['id'],
|
|
|
+ 'name' => (string) $role['name'],
|
|
|
+ 'description' => (string) ($role['description'] ?? ''),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Parses a positive role ID from route arguments.
|
|
|
+ *
|
|
|
+ * @param array<string, mixed> $args Route arguments.
|
|
|
+ * @return int|null Positive role ID, or null for invalid input.
|
|
|
+ * @side-effects None.
|
|
|
+ */
|
|
|
+ private function roleId(array $args): ?int
|
|
|
+ {
|
|
|
+ $id = filter_var($args['id'] ?? null, FILTER_VALIDATE_INT);
|
|
|
+ return $id === false || $id < 1 ? null : $id;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Locks user rows after role rows for a role mutation.
|
|
|
+ *
|
|
|
+ * @param SQL $database Active database connection.
|
|
|
+ * @return void
|
|
|
+ * @side-effects Acquires InnoDB row locks for the current transaction.
|
|
|
+ */
|
|
|
+ private function lockUsersForMutation(SQL $database): void
|
|
|
+ {
|
|
|
+ $database->exec('SELECT id FROM ' . self::USERS_TABLE . ' FOR UPDATE');
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Locks role rows before user rows for a role mutation.
|
|
|
+ *
|
|
|
+ * @param SQL $database Active database connection.
|
|
|
+ * @return void
|
|
|
+ * @side-effects Acquires InnoDB row locks for the current transaction.
|
|
|
+ */
|
|
|
+ private function lockRolesForMutation(SQL $database): void
|
|
|
+ {
|
|
|
+ $database->exec('SELECT id FROM ' . self::ROLES_TABLE . ' FOR UPDATE');
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Raises a stable conflict for protected or assigned roles.
|
|
|
+ *
|
|
|
+ * @return RoleApiException Conflict exception for deletion failures.
|
|
|
+ * @side-effects None.
|
|
|
+ */
|
|
|
+ private function deletionConflict(): RoleApiException
|
|
|
+ {
|
|
|
+ return new RoleApiException(self::STATUS_CONFLICT, 'ROLE_DELETE_CONFLICT', 'The role cannot be deleted.');
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Converts a database uniqueness violation into the role-name conflict response.
|
|
|
+ *
|
|
|
+ * @param PDOException $exception Database exception raised by a write.
|
|
|
+ * @return void Throws a stable conflict for duplicate names.
|
|
|
+ * @side-effects None.
|
|
|
+ */
|
|
|
+ private function throwIfDuplicateName(PDOException $exception): void
|
|
|
+ {
|
|
|
+ if ($exception->getCode() === '23000' || str_contains(strtolower($exception->getMessage()), 'duplicate')) {
|
|
|
+ throw new RoleApiException(self::STATUS_CONFLICT, 'ROLE_NAME_CONFLICT', 'Role name already exists.', $exception);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+final class RoleApiException extends \RuntimeException
|
|
|
+{
|
|
|
+ /**
|
|
|
+ * Creates a structured role API exception.
|
|
|
+ *
|
|
|
+ * @param int $status HTTP status code.
|
|
|
+ * @param string $errorCode Stable application error code.
|
|
|
+ * @param string $message Safe client-facing message.
|
|
|
+ * @param \Throwable|null $previous Original exception, when applicable.
|
|
|
+ * @return void Initializes the exception.
|
|
|
+ * @side-effects None.
|
|
|
+ */
|
|
|
+ public function __construct(
|
|
|
+ private readonly int $status,
|
|
|
+ private readonly string $errorCode,
|
|
|
+ string $message,
|
|
|
+ ?\Throwable $previous = null
|
|
|
+ ) {
|
|
|
+ parent::__construct($message, 0, $previous);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Returns the HTTP status associated with the exception.
|
|
|
+ *
|
|
|
+ * @return int HTTP status code.
|
|
|
+ * @side-effects None.
|
|
|
+ */
|
|
|
+ public function status(): int
|
|
|
+ {
|
|
|
+ return $this->status;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Returns the stable application error code.
|
|
|
+ *
|
|
|
+ * @return string Error code.
|
|
|
+ * @side-effects None.
|
|
|
+ */
|
|
|
+ public function errorCode(): string
|
|
|
+ {
|
|
|
+ return $this->errorCode;
|
|
|
+ }
|
|
|
+}
|