<?php
declare(strict_types=1);

namespace Entities\Permissions\Model;

use Infrastructure\Database\DataMappers\TableMapper;
use Infrastructure\Database\Postgres;

// fake Singleton with public constructor
final class PermissionsTableMapper extends TableMapper
{
    const TABLE_NAME = 'permissions';
    const UPDATE_FIELDS = ['title', 'description', 'active'];

    const ORDER_BY_COLUMN_NAME = 'title';

    public static function getInstance()
    {
        static $instance = null;
        if ($instance === null) {
            $instance = new PermissionsTableMapper();
        }
        return $instance;
    }

    public function __construct()
    {
        parent::__construct(self::TABLE_NAME, '*', self::ORDER_BY_COLUMN_NAME);
    }

    public function callInsert(string $title, ?string $description = null, bool $active = true): int
    {
        $columnValues = [
            'title' => $title,
            'description' => $description,
            'active' => Postgres::convertBoolToPostgresBool($active),
        ];

        return (int) parent::insert($columnValues);
    }

    public function isUpdatable(): bool
    {
        return true;
    }

    public function isDeletable(): bool
    {
        return true;
    }

    /** note this allows any fields to be passed in changedFields and returns only changes from the UPDATE_FIELDS array */
    public function getChangedFields(array $changedFields): array
    {
        $changedPermissionFields = [];

        foreach ($changedFields as $fieldName => $fieldInfo) {
            if (in_array($fieldName, self::UPDATE_FIELDS)) {
                switch($fieldName) {
                    case 'active':
                        $changedPermissionFields['active'] = Postgres::convertBoolToPostgresBool($changedFields['active']);
                        break;

                    default:
                        $changedPermissionFields[$fieldName] = $changedFields[$fieldName];
                }
            }
        }
        return $changedPermissionFields;
    }

    /** deletes the permissions record */
    public function delete(int $permissionId): ?string
    {
        return parent::deleteByPrimaryKey($permissionId, 'title');
    }
}


<?php
declare(strict_types=1);

namespace Entities\Permissions\View\Forms;

use Entities\Permissions\Model\Permission;
use Slim\Container;

class PermissionUpdateForm extends PermissionForm
{
    public function __construct(string $formAction, Container $container, array $fieldValues = [])
    {
        parent::__construct($formAction, $container, true, $fieldValues);
    }

    /** note could subclass here for initial get form but simpler to just add this fn */
    public function setFieldValuesToPermission(Permission $permission)
    {
        parent::setFieldValues([
            parent::TITLE_FIELD_NAME => $permission->getTitle(),
            parent::DESCRIPTION_FIELD_NAME => $permission->getDescription(),
            parent::ROLES_FIELDSET_NAME => $permission->getRoleIds(),
            parent::ACTIVE_FIELD_NAME => $permission->getActive(),
        ]);
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Database\Queries;

class UpdateBuilder extends InsertUpdateBuilder {
    public $updateOnColumnName;
    public $updateOnColumnValue;
    public $setColumnsValues;

    function __construct(string $dbTable, string $updateOnColumnName, $updateOnColumnValue)
    {
        $this->updateOnColumnName = $updateOnColumnName;
        $this->updateOnColumnValue = $updateOnColumnValue;
        parent::__construct($dbTable);
    }

    /**
     * adds column to update query
     * @param string $name
     * @param $value
     */
    public function addColumn(string $name, $value)
    {
        $this->args[] = $value;
        if (count($this->args) > 1) {
            $this->setColumnsValues .= ", ";
        }
        $argNum = count($this->args);
        $this->setColumnsValues .= "$name = \$".$argNum;
    }

    /**
     * @param array $updateColumns
     */
    public function addColumnsArray(array $updateColumns)
    {
        foreach ($updateColumns as $name => $value) {
            $this->addColumn($name, $value);
        }
    }

    /**
     * sets update query
     */
    public function setSql()
    {
        $this->args[] = $this->updateOnColumnValue;
        $lastArgNum = count($this->args);
        $this->sql = "UPDATE $this->dbTable SET $this->setColumnsValues WHERE $this->updateOnColumnName = $".$lastArgNum;
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Security\Authorization;

use Entities\Administrators\Model\Administrator;
use Entities\Administrators\Model\AdministratorsEntityMapper;
use Entities\Permissions\Model\PermissionsEntityMapper;
use Exceptions;

use Infrastructure\SlimPostgres;

class AuthorizationService
{
    /** $resource matches permission.title */
    public function isAuthorized(string $resource): bool
    {
        // get permission model object
        if (null === $permission = (PermissionsEntityMapper::getInstance())->getObjectByTitle($resource, true)) {
            throw new Exceptions\QueryResultsNotFoundException("Permission not found for: $resource");
        }

        // authorized if administrator has at least one role assigned to permission
        return $this->getLoggedInAdministrator()->hasOneRoleByObject($permission->getRoles());
    }

    /** should not be called by resources that don't require authentication */
    public function getLoggedInAdministrator(): Administrator
    {
        if (!isset($_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID])) {
            throw new \Exception("No one is logged in");
        }

        if (null === $administrator = (AdministratorsEntityMapper::getInstance())->getObjectById($_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID])) {
            unset($_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID]); /** remove for security */
            throw new \Exception("Invalid administrator id ".$_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID]." in session");
        }

        return $administrator;
    }

    public function hasTopRole(): bool
    {
        return $this->getLoggedInAdministrator()->hasTopRole();
    }
}


<?php
declare(strict_types=1);

namespace Entities\Administrators\Model;

use Entities\Events\EventsTableMapper;
use Infrastructure\Security\Authentication\AuthenticationService;
use Infrastructure\Security\Authorization\AuthorizationService;
use Entities\Roles\Model\Role;
use Entities\Roles\Model\RolesTableMapper;
use Infrastructure\BaseEntity\BaseMVC\Model\ListViewModels;
use Infrastructure\Database\Queries\QueryBuilder;
use Infrastructure\Database\Postgres;
use Exceptions\UnallowedActionException;
use Infrastructure\Functions;

// the model of an Administrator stored in the database, which includes the record from administrators, plus an array of assigned roles.
class Administrator implements ListViewModels
{
    /** int */
    private $id;

    /** string (nullable) */
    private $name;

    /** string */
    private $username;

    /** string */
    private $passwordHash;

    /** @var \SlimPostgres\Administrators\Roles[] an array of assigned role objects */
    private $roles;

    /** @var string[] array of assigned role ids */
    private $roleIds;

    /** @var string[] array of assigned role names */
    private $roleNames;

    /** bool */
    private $active;

    /** DateTimeImmutable */
    private $created;

    /** only used for certain functions. set in constructor or setAuth() */
    private $authentication;
    private $authorization;

    private $notDeletableReason;

    public function __construct(int $id, string $name, string $username, string $passwordHash, array $roles, bool $active, \DateTimeImmutable $created, ?AuthenticationService $authentication = null, ?AuthorizationService $authorization = null)
    {
        // validate roles array is array of role objects
        if (count($roles) == 0) {
            throw new \InvalidArgumentException("Roles cannot be empty for administrator (id $id)");
        }
        foreach ($roles as $role) {
            if (get_class($role) != 'Entities\Roles\Model\Role') {
                throw new \InvalidArgumentException("Invalid role in roles");
            }
        }
        $this->id = $id;
        $this->name = $name;
        $this->username = $username;
        $this->passwordHash = $passwordHash;
        $this->roles = $roles;
        $this->active = $active;
        $this->created = $created;

        $this->authentication = $authentication;
        $this->authorization = $authorization;

        $this->setRoleIdsNames();
    }

    private function setRoleIdsNames()
    {
        $roleIds = [];
        $roleNames = [];
        foreach ($this->roles as $role) {
            $roleIds[] = $role->getid();
            $roleNames[] = $role->getRoleName();
        }
        $this->roleIds = $roleIds;
        $this->roleNames = $roleNames;
    }

    public function hasRole(int $roleId): bool
    {
        return in_array($roleId, $this->roleIds);
    }

    /** returns true if has at least one role from array of role ids */
    public function hasOneRole(array $roleIds): bool
    {
        foreach ($roleIds as $roleId) {
            if ($this->hasRole((int) $roleId)) {
                return true;
            }
        }
        return false;
    }

    public function hasRoleByObject(Role $role): bool
    {
        return $this->hasRole($role->getId());
    }

    public function hasOneRoleByObject(array $roles): bool
    {
        foreach ($roles as $role) {
            if ($this->hasRoleByObject($role)) {
                return true;
            }
        }
        return false;
    }

    // returns true if this administrator has top role in roles array.
    public function hasTopRole(): bool
    {
        return $this->hasRoleName(TOP_ROLE);
    }

    public function hasRoleName(string $roleName): bool
    {
        $rolesTableMapper = RolesTableMapper::getInstance();
        if (!in_array($roleName, $rolesTableMapper->getRoleNames())) {
            throw new \InvalidArgumentException("Invalid role $roleName");
        }

        return in_array($roleName, $this->roleNames);
    }

    /** returns string of assigned roles ie role1, role2, ...  */
    public function getRolesString(): string
    {
        $rolesString = "";
        foreach ($this->roleNames as $role) {
            $rolesString .= "$role, ";
        }
        return Functions::removeLastCharsFromString($rolesString, 2);
    }

    // getters

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getUsername(): string
    {
        return $this->username;
    }

    public function getPasswordHash(): string
    {
        return $this->passwordHash;
    }

    public function getRoles(): array
    {
        return $this->roles;
    }

    public function getActive(): bool
    {
        return $this->active;
    }

    public function getCreated(): \DateTimeImmutable
    {
        return $this->created;
    }

    public function getRoleIds(): array
    {
        return $this->roleIds;
    }

    public function getRoleNames(): array
    {
        return $this->roleNames;
    }

    public function getAuthorization(): ?AuthorizationService
    {
        return $this->authorization;
    }

    /** returns array of list view fields [fieldName => fieldValue] */
    public function getListViewFields(): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'username' => $this->username,
            'roles' => $this->getRolesString(),
            'active' => Postgres::convertBoolToPostgresBool($this->active), // send 't' / 'f'
            'created' => $this->created->format('Y-m-d'),
        ];
    }

    public function setAuth(AuthenticationService $authentication, AuthorizationService $authorization)
    {
        $this->authentication = $authentication;
        $this->authorization = $authorization;
    }

    /** whether model is allowed to be updated
     *  do not allow non-owners to edit owners
     */
    public function isUpdatable(): bool
    {
        if (is_null($this->authorization)) {
            throw new \Exception("Authorization must be set");
        }

        // top dogs can update
        if ($this->authorization->hasTopRole()) {
            return true;
        }

        // non-top dogs can be updated
        if (!$this->hasTopRole()) {
            return true;
        }

        return false;
    }

    /** whether this model is allowed to be deleted
     *  do not allow admin to delete themself or non-owners to delete owners
     */
    public function isDeletable(): bool
    {
        if (is_null($this->authorization)) {
            throw new \Exception("Authorization must be set");
        }

        $id = $this->getId();

        // make sure the current administrator is not deleting her/himself
        if ($this->isLoggedIn()) {
            $this->notDeletableReason = "Administrator cannot delete own account: id $id";
            return false;
        }

        // non-top dogs cannot delete top dogs
        if (!$this->getAuthorization()->hasTopRole() && $this->hasTopRole()) {
            $this->notDeletableReason = "Not authorized to delete administrator: id $id";
            return false;
        }

        // make sure there are no events for administrator being deleted
        if ((EventsTableMapper::getInstance())->existForAdministrator($id)) {
            $this->notDeletableReason = "Events exist for administrator: id $id";
            return false;
        }

        return true;
    }

    public function isId(int $id): bool
    {
        return $id == $this->id;
    }

    public function getUniqueId(): ?string
    {
        return (string) $this->id;
    }

    public function isLoggedIn(): bool
    {
        if (is_null($this->authentication)) {
            throw new \Exception("Authentication must be set");
        }

        return $this->id === $this->authentication->getAdministratorId();
    }

    public function isActive(): bool
    {
        return $this->getActive();
    }

    public function getNotDeletableReason(): ?string
    {
        return $this->notDeletableReason;
    }
}


<?php
declare(strict_types=1);

namespace Entities\Roles;

use Entities\Roles\Model\RolesTableMapper;
use Infrastructure\SlimPostgres;
use Exceptions\QueryFailureException;
use Infrastructure\BaseEntity\BaseMVC\View\ObjectsListViews;
use Infrastructure\BaseEntity\BaseMVC\View\InsertUpdateViews;
use Infrastructure\Database\Queries\QueryBuilder;
use Infrastructure\BaseEntity\DatabaseTable\View\DatabaseTableView;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;
use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;

class RolesView extends DatabaseTableView implements ObjectsListViews, InsertUpdateViews
{
    public function __construct(Container $container)
    {
        parent::__construct($container, RolesTableMapper::getInstance(), ROUTEPREFIX_ROLES, true, 'Admin/Lists/objectsList.php');
    }

    protected function getResource(string $which): string
    {
        switch ($which) {
            case 'insert':
                return ROLES_INSERT_RESOURCE;
                break;
            case 'update':
                return ROLES_UPDATE_RESOURCE;
                break;
            case 'delete':
                return ROLES_DELETE_RESOURCE;
                break;
            default:
                throw new \InvalidArgumentException("Undefined resource $which");
        }
    }

    /** overrides in order to get objects and send to indexView */
    public function routeIndex(Request $request, Response $response, $args)
    {
        return $this->indexViewObjects($response);
    }

    /** overrides in order to get objects and send to indexView */
    public function routeIndexResetFilter(Request $request, Response $response, $args)
    {
        // redirect to the clean url
        return $this->indexViewObjects($response, true);
    }

    /** get role objects and send to parent indexView */
    public function indexViewObjects(Response $response, bool $resetFilter = false)
    {
        if ($resetFilter) {
            return $this->resetFilter($response, $this->indexRoute);
        }

        try {
            $roles = $this->mapper->getObjects($this->getFilterColumnsInfo());
        } catch (QueryFailureException $e) {
            $roles = [];
            // warning is inserted when query fails
            SlimPostgres::setAdminNotice('Query Failed', 'failure');
        }

        return $this->indexView($response, $roles);
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Database\Queries;

use Infrastructure\Database\Postgres;
use Exceptions\QueryFailureException;
use Exceptions\QueryResultsNotFoundException;

class QueryBuilder
{
    protected $sql;
    protected $args = array();
    const OPERATORS = ['=', '!=', '<', '>', '<=', '>=', 'IS', 'IS NOT', 'LIKE', 'ILIKE'];

    /**
     * QueryBuilder constructor. like add, for convenience
     */
    function __construct()
    {
        $args = func_get_args();
        // note func_num_args returns 0 if just 1 argument of null passed in
        if (count($args) > 0) {
            call_user_func_array(array($this, 'add'), $args);
        }
    }

    /**
     * appends sql and args to query
     * @param string $sql
     * @return $this
     */
    public function add(string $sql)
    {
        $args = func_get_args();
        array_shift($args); // drop the first one (the sql string)
        $this->sql .= $sql;
        $this->args = array_merge($this->args, $args);
        return $this;
    }

    /**
     * handle null argument for correct sql
     * @param string $name
     * @param $arg
     * @return $this
     */
    public function null_eq(string $name, $arg)
    {
        if ($arg === null) {
            $this->sql .= "$name is null";
        }
        else {
            $this->args[] = $arg;
            $argNum = count($this->args);
            $this->sql .= "$name = \$$argNum";
        }
        return $this;
    }

    /**
     * sets sql and args
     * @param string sql
     * @param $args
     */
    public function set(string $sql, array $args)
    {
        $this->sql = $sql;
        $this->args = $args;
    }

    private function alterBooleanArgs()
    {
        foreach ($this->args as $argIndex => $arg) {
            if (is_bool($arg)) {
                $this->args[$argIndex] = ($arg) ? self::BOOLEAN_TRUE : self::BOOLEAN_FALSE;
                $this->args[$argIndex] = self::convertBoolToPostgresBool($arg);
            }
        }
    }

    public function execute(bool $alterBooleanArgs = false)
    {
        if ($alterBooleanArgs) {
            $this->alterBooleanArgs();
        }

        $postgresConnection = (Postgres::getInstance())->getConnection();

        /** query failures within transactions without suppressing errors for pg_query_params caused two errors, only 1 of which was inserted to the database log */
        if (!$result = @pg_query_params($postgresConnection, $this->sql, $this->args)) {
            /** note pg_last_error seems to often not return anything, but pg_query_params call will result in php warning */
            $msg = pg_last_error($postgresConnection) . " " . $this->sql;
            if (count($this->args) > 0) {
                $msg .= PHP_EOL . " Args: " . var_export($this->args, true);
            }

            throw new QueryFailureException($msg, E_ERROR);
        }

        $this->resetQuery(); /** prevent accidental multiple execution */
        return $result;
    }

    /**
     * In order to receive a column value back for INSERT, UPDATE, and DELETE queries
     * Note that RETURNING can include multiple fields or expressions in SQL, but this only accepts one field. To receive multiple, simply call execute() instead and process the returned result similar to below
     * Note also that if an invalid $returnField is received, the query still executes prior to throwing the InvalidArgumentException.
     */
    public function executeWithReturnField(string $returnField, bool $alterBooleanArgs = false)
    {
        $this->add(" RETURNING $returnField");

        /** note, if query fails exception thrown in execute */
        $result = $this->execute($alterBooleanArgs);

        if (pg_num_rows($result) > 0) {
            $returned = pg_fetch_all($result);
            if (!isset($returned[0][$returnField])) {
                throw new \InvalidArgumentException("Query executed, but $returnField column does not exist");
            }
            return $returned[0][$returnField];
        } else {
            /** nothing was found - ie an update or delete that found no matches to the WHERE clause */
            throw new QueryResultsNotFoundException();
        }
    }

    /**
     * returns the value of the one column in one record or null if 0 records result
     */
    public function getOne(): ?string
    {
        $result = $this->execute();
        if (pg_num_rows($result) == 1) {
            // make sure only 1 field in query
            if (pg_num_fields($result) == 1) {
                return pg_fetch_array($result)[0];
            }
            else {
                throw new \Exception("Too many result fields");
            }
        }
        else {
            // either 0 or multiple records in result
            // if 0
            if (pg_num_rows($result) == 0) {
                // no error here. client can error if appropriate
                return null;
            }
            else {
                throw new \Exception("Multiple results");
            }
        }
    }

    public static function validateWhereOperator(string $op): bool
    {
        return in_array($op, self::OPERATORS);
    }

    public static function getWhereOperatorsText(): string
    {
        $ops = "";
        $opCount = 1;
        foreach (self::OPERATORS as $op) {
            $ops .= "$op";
            if ($opCount < count(self::OPERATORS)) {
                $ops .= ", ";
            }
            $opCount++;
        }
        return $ops;
    }

    public function getSql(): ?string
    {
        if (isset($this->sql)) {
            return $this->sql;
        }

        return null;
    }

    public function getArgs(): array
    {
        return $this->args;
    }

    public function resetQuery()
    {
        $this->sql = '';
        $this->args = [];
    }

}


<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\BaseMVC\View;

use Slim\Container;
use Infrastructure\Database\Postgres;

/**
 * navigation for admin pages
 */
class AdminNavigation
{
    private $nav;
    private $container;

    function __construct(Container $container)
    {
        $this->container = $container;
        $this->setNav();
    }

    /** entire nav without regard to permissions */
    private function setNav()
    {
        $this->nav = [

            'System' => [
                'subSections' => [
                    'Administrators' => [
                        'route' => ROUTE_ADMINISTRATORS,
                        'authorization' => ADMINISTRATORS_VIEW_RESOURCE,
                        'subSections' => [
                            'Insert' => [
                                'route' => ROUTE_ADMINISTRATORS_INSERT,
                                'authorization' => ADMINISTRATORS_INSERT_RESOURCE,
                            ],
                        ]
                    ],

                    'Roles' => [
                        'route' => ROUTE_ADMINISTRATORS_ROLES,
                        'authorization' => ROLES_VIEW_RESOURCE,
                        'subSections' => [
                            'Insert' => [
                                'route' => ROUTE_ADMINISTRATORS_ROLES_INSERT,
                                'authorization' => ROLES_INSERT_RESOURCE,
                            ],
                        ],
                    ],

                    'Permissions' => [
                        'route' => ROUTE_ADMINISTRATORS_PERMISSIONS,
                        'authorization' => PERMISSIONS_VIEW_RESOURCE,
                        'subSections' => [
                            'Insert' => [
                                'route' => ROUTE_ADMINISTRATORS_PERMISSIONS_INSERT,
                                'authorization' => PERMISSIONS_INSERT_RESOURCE,
                            ],
                        ]
                    ],

                    'Events' => [
                        'route' => ROUTE_EVENTS,
                        'authorization' => EVENTS_VIEW_RESOURCE,
                        'subSections' => [
                            'Types' => [
                                'route' => ROUTE_DATABASE_TABLES,
                                'args' => [ROUTEARG_DATABASE_TABLE_NAME => 'event_types'],
                                'authorization' => EVENTS_VIEW_RESOURCE,
                            ],
                        ]
                    ],

                    'Database' => [
                        'authorization' => DATABASE_TABLES_VIEW_RESOURCE,
                        'subSections' => $this->getDatabaseTablesSection()
                    ],

                ]
            ],
            'Logout' => [
                'route' => ROUTE_LOGOUT,
            ],
        ];

        if (isset($this->container['settings']['adminNav'])) {
            if (!is_array($this->container['settings']['adminNav'])) {
                throw new \Exception("adminNav config must be array");
            }

            $this->nav = array_merge($this->container['settings']['adminNav'], $this->nav);
        }
    }

    /** table view is allowed unless settings[table] is set false */
    private function isDatabaseTableViewAllowed(string $table) : bool
    {
        return true;
    }

    private function getDatabaseTablesSection(): array
    {
        $section = [];
        $tables = Postgres::getSchemaTables();

        foreach ($tables as $table) {
            if ($this->isDatabaseTableViewAllowed($table)) {
                $section[$table] = [
                    'route' => ROUTE_DATABASE_TABLES,
                    'args' => [ROUTEARG_DATABASE_TABLE_NAME => $table]
                ];
            }
        }
        return $section;
    }

    /** add nav components as necessary based on logged in administrator role */
    private function getSectionForAdministrator(array $section, string $sectionName): array
    {
        if (isset($section['authorization']) && !$this->container->authorization->isAuthorized($section['authorization'])) {
            return [];
        }

        // rebuild based on permissions
        $updatedSection = [];
        foreach ($section as $key => $value) {
            if ($key != 'subSections') {
                $updatedSection[$key] = $value;
            }
        }

        $updatedSubSections = [];
        if (isset($section['subSections'])) {
            foreach ($section['subSections'] as $subSectionName => $subSection) {

                $updatedSubSection = $this->getSectionForAdministrator($subSection, $subSectionName);
                if (count($updatedSubSection) > 0) {
                    $updatedSubSections[$subSectionName] = $updatedSubSection;
                }
            }
        }

        if (count($updatedSubSections) > 0) {
            $updatedSection['subSections'] = $updatedSubSections;
        }

        return $updatedSection;

    }

    public function getNavForAdministrator()
    {
        $nav = []; // rebuild nav sections based on authorization for this user

        foreach ($this->nav as $sectionName => $section) {
            $updatedSection = $this->getSectionForAdministrator($section, $sectionName);
            if (count($updatedSection) > 0) {
                $nav[$sectionName] = $updatedSection;
            }
        }

        return $nav;
    }
}


<?php
declare(strict_types=1);

namespace Entities\Roles;

use Entities\Roles\Model\RolesTableMapper;
use Infrastructure\SlimPostgres;
use Infrastructure\BaseEntity\DatabaseTable\DatabaseTableController;
use Exceptions;
use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;

class RolesController extends DatabaseTableController
{
    public function __construct(Container $container)
    {
        parent::__construct($container, RolesTableMapper::getInstance(), new RolesView($container), ROUTEPREFIX_ROLES);
    }

    /** override to call objects view */
    public function routePostIndexFilter(Request $request, Response $response, $args)
    {
        $this->setIndexFilter($request, $response, $args, $this->getListViewColumns(), $this->view);
        return $this->view->indexViewObjects($response);
    }

    // override to check exceptions
    public function routeGetDelete(Request $request, Response $response, $args)
    {
        if (!$this->authorization->isAuthorized(ROLES_DELETE_RESOURCE)) {
            throw new \Exception('No permission.');
        }

        $primaryKey = $args[ROUTEARG_PRIMARY_KEY];
        $tableName = $this->tableMapper->getFormalTableName(false);
        $primaryKeyColumnName = $this->tableMapper->getPrimaryKeyColumnName();

        try {
            $this->tableMapper->deleteByPrimaryKey($primaryKey);
            $this->events->insertInfo(EVENT_ROLE_DELETE, [$primaryKeyColumnName => $primaryKey]);
            SlimPostgres::setAdminNotice("Deleted $tableName $primaryKey");
        } catch (Exceptions\UnallowedActionException $e) {
            $this->events->insertWarning(EVENT_UNALLOWED_ACTION, ['error' => $e->getMessage()]);
            SlimPostgres::setAdminNotice($e->getMessage(), 'failure');
        } catch (Exceptions\QueryResultsNotFoundException $e) {
define('EVENT_QUERY_NO_RESULTS', 'Query Results Not Found');
            $this->events->insertWarning(EVENT_QUERY_NO_RESULTS, $e->getMessage());
            SlimPostgres::setAdminNotice($e->getMessage(), 'failure');
        } catch (Exceptions\QueryFailureException $e) {
            $this->events->insertError(EVENT_QUERY_FAIL, ['error' => $e->getMessage()]);
            SlimPostgres::setAdminNotice('Delete Failed', 'failure');
        }

        return $response->withRedirect($this->router->pathFor(SlimPostgres::getRouteName(true, $this->routePrefix, 'index')));
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\DatabaseTable;

use Infrastructure\SlimPostgres;
use Exceptions;
use Infrastructure\BaseEntity\BaseMVC\View\ResponseUtilities;
use Infrastructure\BaseEntity\BaseMVC\Controller\AdminController;
use Infrastructure\Database\DataMappers\TableMapper;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;
use Infrastructure\BaseEntity\DatabaseTable\View\DatabaseTableForm;
use Infrastructure\BaseEntity\DatabaseTable\Model\DatabaseTableInsertFormValidator;
use Infrastructure\BaseEntity\DatabaseTable\Model\DatabaseTableUpdateFormValidator;
use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;

class DatabaseTableController extends AdminController
{
    use ResponseUtilities;

    protected $tableMapper;
    protected $view;
    protected $routePrefix;

    public function __construct(Container $container, TableMapper $tableMapper, $view, $routePrefix)
    {
        $this->tableMapper = $tableMapper;
        $this->view = $view;
        $this->routePrefix = $routePrefix;
        parent::__construct($container);
    }

    public function getMapper(): TableMapper
    {
        return $this->tableMapper;
    }

    protected function getListViewColumns(): array
    {
        $listViewColumns = [];
        foreach ($this->tableMapper->getColumns() as $column) {
            $listViewColumns[$column->getName()] = $column->getName();
        }

        return $listViewColumns;
    }

    public function routePostIndexFilter(Request $request, Response $response, $args)
    {
        $this->setIndexFilter($request, $response, $args, $this->getListViewColumns(), $this->view);
        return $this->view->indexView($response);
    }

    public function routePostInsert(Request $request, Response $response, $args)
    {
        if (!$this->authorization->isAuthorized(constant(strtoupper($this->routePrefix)."_INSERT_RESOURCE"))) {
            throw new \Exception('No permission.');
        }

        /** note that boolean columns that don't exist in request input are added as false */
        $this->setRequestInput($request, DatabaseTableForm::getFieldNames($this->tableMapper), $this->tableMapper->getBooleanColumnNames());

        $validator = new DatabaseTableInsertFormValidator($this->requestInput, $this->tableMapper);

        if (!$validator->validate()) {
            // redisplay the form with input values and error(s)
            FormHelper::setFieldErrors($validator->getFirstErrors());
            $args[SlimPostgres::USER_INPUT_KEY] = $this->requestInput;
            return $this->view->insertView($request, $response, $args);
        }

        /** if primary key is set the new id is returned by mapper insert method */
        $insertResult = $this->tableMapper->insert($this->requestInput);

        $this->enterEventAndNotice('insert', $insertResult);

        return $response->withRedirect($this->router->pathFor(SlimPostgres::getRouteName(true, $this->routePrefix, 'index')));
    }

    /** called by insert and update */
    private function enterEventAndNotice(string $action, $primaryKeyValue = null)
    {
        if ($action != 'insert' && $action != 'update') {
            throw new \InvalidArgumentException("Action must be either insert or update");
        }

        $actionPastTense = ($action == 'insert') ? 'inserted' : 'updated';

        $tableNameSingular = $this->tableMapper->getFormalTableName(false);
        $noteStart = "$actionPastTense $tableNameSingular";

        /** use event constant if defined, squelch warning */
        $eventTitle = @constant("EVENT_".strtoupper($tableNameSingular)."_".strtoupper($action)) ?? $noteStart;
        $adminNotification = $noteStart;
        $eventPayload = [];

        if (null !== $primaryKeyColumnName = $this->tableMapper->getPrimaryKeyColumnName()) {
            $adminNotification .= " $primaryKeyValue"; // if primary key is set the new id is returned by mapper insert method
            $eventPayload = [$primaryKeyColumnName => $primaryKeyValue];
        }

        $eventPayload = array_merge($eventPayload, $this->requestInput);

        $this->events->insertInfo($eventTitle, $eventPayload);
        SlimPostgres::setAdminNotice($adminNotification);
    }

    /** the table must have a primary key column defined */
    public function routePutUpdate(Request $request, Response $response, $args)
    {
        if (!$this->authorization->isAuthorized(constant(strtoupper($this->routePrefix)."_UPDATE_RESOURCE"))) {
            throw new \Exception('No permission.');
        }

        $primaryKeyValue = $args[ROUTEARG_PRIMARY_KEY];

        /** note that boolean columns that don't exist in request input are added as false */
        $this->setRequestInput($request, DatabaseTableForm::getFieldNames($this->tableMapper), $this->tableMapper->getBooleanColumnNames());

        $redirectRoute = SlimPostgres::getRouteName(true, $this->routePrefix, 'index');

        // make sure there is a record for the primary key
        if (null === $record = $this->tableMapper->selectForPrimaryKey($primaryKeyValue)) {
            return $this->databaseRecordNotFound($response, $primaryKeyValue, $this->tableMapper, 'update');
        }

        // if no changes made stay on page with error
        $changedColumnsValues = $this->getMapper()->getChangedColumnsValues($this->requestInput, $record);
        if (count($changedColumnsValues) == 0) {
            SlimPostgres::setAdminNotice("No changes made", 'failure');
            return $this->view->updateView($request, $response, $args);
        }

        $validator = new DatabaseTableUpdateFormValidator($this->requestInput, $this->tableMapper, $record);

        if (!$validator->validate()) {
            // redisplay the form with input values and error(s)
            FormHelper::setFieldErrors($validator->getFirstErrors());
            $args[SlimPostgres::USER_INPUT_KEY] = $this->requestInput;
            return $this->view->updateView($request, $response, $args);
        }

        $this->tableMapper->updateByPrimaryKey($changedColumnsValues, $primaryKeyValue);

        $this->enterEventAndNotice('update', $primaryKeyValue);

        return $response->withRedirect($this->router->pathFor($redirectRoute));
    }

    public function routeGetDelete(Request $request, Response $response, $args)
    {
        if (!$this->authorization->isAuthorized(constant(strtoupper($this->routePrefix)."_DELETE_RESOURCE"))) {
            throw new \Exception('No permission.');
        }

        $primaryKey = $args[ROUTEARG_PRIMARY_KEY];
        $tableName = $this->tableMapper->getFormalTableName(false);
        $primaryKeyColumnName = $this->tableMapper->getPrimaryKeyColumnName();

        try {
            $this->tableMapper->deleteByPrimaryKey($primaryKey);

            /** use constant if defined, squelch warning */
            $eventTitle = @constant("EVENT_".strtoupper($tableName)."_DELETE") ?? "Deleted $tableName";

            $this->events->insertInfo($eventTitle, [$primaryKeyColumnName => $primaryKey]);
            SlimPostgres::setAdminNotice("Deleted $tableName $primaryKey");
        } catch (Exceptions\QueryResultsNotFoundException $e) {
            $this->events->insertWarning(EVENT_QUERY_NO_RESULTS, ['table' => $tableName, $primaryKeyColumnName => $primaryKey]);
            SlimPostgres::setAdminNotice("$tableName $primaryKey Not Found", 'failure');
        } catch (Exceptions\QueryFailureException $e) {
            SlimPostgres::setAdminNotice('Deletion Query Failure', 'failure');
        }

        return $response->withRedirect($this->router->pathFor(SlimPostgres::getRouteName(true, $this->routePrefix, 'index')));
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Database\Queries;

class InsertBuilder extends InsertUpdateBuilder
{
    /** @var string */
    protected $columns = '';

    /** @var string */
    protected $values = '';

    /**
     * adds column to insert query
     * @param string $name
     * @param $value
    */
    public function addColumn(string $name, $value)
    {
        $this->args[] = $value;
        if (mb_strlen($this->columns) > 0) {
            $this->columns .= ", ";
        }
        $this->columns .= $name;
        if (mb_strlen($this->values) > 0) {
            $this->values .= ", ";
        }
        $argNum = count($this->args);
        $this->values .= "$".$argNum;
    }

    /**
     * sets insert query
     */
    public function setSql()
    {
        $this->sql = "INSERT INTO $this->dbTable ($this->columns) VALUES($this->values)";
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\BaseMVC\View;

use Infrastructure\SlimPostgres;
use Infrastructure\Database\DataMappers\TableMapper;
use Slim\Container;
use Slim\Http\Response;

trait ResponseUtilities
{
    // this can be used when a record is not found for the given primary key for update and delete attempts
    private function databaseRecordNotFound(Response $response, $primaryKey, TableMapper $tableMapper, string $routeAction, ?string $title = null)
    {
        // $routeAction must be 'update' or 'delete'
        if ($routeAction != 'update' && $routeAction != 'delete') {
            throw new \Exception("routeAction $routeAction must be update or delete");
        }

        // enter event
        $this->events->insertWarning(EVENT_QUERY_NO_RESULTS, [$tableMapper->getPrimaryKeyColumnName() => $primaryKey, 'table' => $tableMapper->getTableName()]);

        $noticeTitle = ($title != null) ? $title: 'Record';
        SlimPostgres::setAdminNotice("$noticeTitle $primaryKey Not Found", 'failure');

        return $response->withRedirect($this->router->pathFor(SlimPostgres::getRouteName(true, $this->routePrefix, 'index')));
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Security\Authentication;

use Infrastructure\SlimPostgres;
use Infrastructure\BaseEntity\BaseMVC\Controller\BaseController;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;
use Slim\Http\Request;
use Slim\Http\Response;

class AuthenticationController extends BaseController
{
    function routePostLogin(Request $request, Response $response, $args)
    {
        $this->setRequestInput($request, $this->authentication->getLoginFields());
        $username = $this->requestInput[$this->authentication->getUsernameFieldName()];
        $password = $this->requestInput[$this->authentication->getPasswordFieldName()];

        $validator = new AuthenticationValidator($this->requestInput, $this->authentication);

        if (!$validator->validate()) {
            // redisplay the form with input values and error(s)
            FormHelper::setFieldErrors($validator->getFirstErrors());
            $av = new AuthenticationView($this->container);
            $args[SlimPostgres::USER_INPUT_KEY] = $this->requestInput;
            return (new AuthenticationView($this->container))->routeGetLogin($request, $response, $args);
        }

        if (!$this->authentication->attemptLogin($username, $password)) {
            $this->insertFailureEvent();

            if ($this->authentication->tooManyFailedLogins()) {
                $this->events->insertSecurity(EVENT_MAX_LOGIN_FAULT, ['number' => $this->authentication->getNumFailedLogins()]);

                // redirect to home page with error message
                $_SESSION[SlimPostgres::SESSION_KEY_NOTICE] = ['Login disabled. Too many failed logins.', 'error'];
                return $response->withRedirect($this->router->pathFor(ROUTE_HOME));
            }

            FormHelper::setGeneralError('Login Unsuccessful');

            // reset password for security.
            $this->requestInput[$this->authentication->getPasswordFieldName()] = '';

            // redisplay the form with input values and error(s), and with 401 unauthenticated status
            $args = array_merge($args, ['status' => 401]);
            return (new AuthenticationView($this->container))->routeGetLogin($request, $response, $args);
        }

        /** successful login */
        $administratorId = $this->authentication->getAdministratorId();
        $administratorUsername = $this->authentication->getAdministratorUsername();
        /** enter null in administrator_id event arg since not logged in at time of request */
        $this->events->insertInfo(EVENT_LOGIN, ['administratorId' => $administratorId, 'username' => $administratorUsername]);
        SlimPostgres::setAdminNotice("Logged in");

        // redirect to proper resource
        if (isset($_SESSION[SlimPostgres::SESSION_KEY_GOTO_ADMIN_PATH])) {
            $redirect = $_SESSION[SlimPostgres::SESSION_KEY_GOTO_ADMIN_PATH];
            unset($_SESSION[SlimPostgres::SESSION_KEY_GOTO_ADMIN_PATH]);
        } else {
            $redirect = $this->router->pathFor($this->authentication->getAdminHomeRouteForAdministrator());
        }

        return $response->withRedirect($redirect);
    }

    private function insertFailureEvent()
    {
        if (null === $reason = $this->authentication->getFailReason()) {
            throw new \Exception("Failure Reason Required");
        }

        /** insert event */
        switch ($reason) {
            case 'password':
                $eventMethod = 'insertInfo';
                $eventPayload = [
                    'reason' => 'Incorrect Password',
                    'administratorId' => $this->authentication->getFailAdministratorId()
                ];
                break;

            case 'nonexistent':
                $eventMethod = 'insertWarning';
                $eventPayload = [
                    'reason' => 'Non-existent Administrator',
                    'username' => $this->authentication->getNonexistentUsername()
                ];
                break;

            case 'inactive':
                $eventMethod = 'insertSecurity';
                $eventPayload = [
                    'reason' => 'Inactive Administrator',
                    'administratorId' => $this->authentication->getFailAdministratorId()
                ];
                break;

            default:
                throw new \Exception("Invalid Failure Reason: $reason");
        }

        $this->events->{$eventMethod}(EVENT_LOGIN_FAIL, $eventPayload);
    }

    /** since this extends AdminController and not AdminController, the administratorId property of Events service must be set for the logout function in order to have it know the current logged in administrator */
    public function routeGetLogout(Request $request, Response $response)
    {
        $this->events->setAdministratorId($this->authentication->getAdministratorId());

        if (null === $username = $this->authentication->getAdministratorUsername()) {
            $this->events->insertWarning(EVENT_LOGOUT_FAULT);
        } else {
            $this->events->insertInfo(EVENT_LOGOUT);
            $this->authentication->logout();
        }

        return $response->withRedirect($this->router->pathFor(ROUTE_HOME));
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Database\Queries;

abstract class InsertUpdateBuilder extends QueryBuilder
{
    protected $dbTable;

    protected $primaryKeyName;

    function __construct(string $dbTable)
    {
        $this->dbTable = $dbTable;
    }

    abstract public function addColumn(string $name, $value);

    abstract public function setSql();

    /**
     * calls appropriate parent method to execute query
     * @return recordset
     */
    public function runExecute(bool $alterBooleanArgs = false)
    {
        if (!isset($this->sql)) {
            $this->setSql();
        }
        if (isset($this->primaryKeyName)) {
            return parent::executeWithReturnField($this->primaryKeyName, $alterBooleanArgs);
        } else {
            return parent::execute($alterBooleanArgs);
        }
    }

    public function setPrimaryKeyName(string $name)
    {
        $this->primaryKeyName = $name;
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\DatabaseTable\Model;

use Infrastructure\Utilities;
use Infrastructure\Utilities\ValitronValidatorExtension;
use Infrastructure\Database\DataMappers\TableMapper;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;

// sets validation rules for database table based on table's columns
abstract class DatabaseTableFormValidator extends ValitronValidatorExtension
{
    private $inputData;
    private $mapper;
    private $databaseTableValidation;

    public function __construct(array $inputData, TableMapper $mapper)
    {
        $this->inputData = $inputData;
        $this->mapper = $mapper;
        $this->databaseTableValidation = new DatabaseTableValidation($this->mapper);
        parent::__construct($inputData, $this->databaseTableValidation->getValidationColumnNames());
    }

    // called by both insert and update children. update will call with skip true and record data so that only columns that have been changed have the unique rule applied, otherwise validation would fail for unchanged unique fields
    protected function setRules(bool $skipUniqueForUnchanged = false, array $record = null)
    {
        $this->mapFieldsRules($this->databaseTableValidation->getValidationRules());

        $uniqueColumns = $this->mapper->getUniqueColumns();

        if (count($uniqueColumns) > 0) {
            $this->addUniqueRule();

            foreach ($uniqueColumns as $databaseColumnMapper) {
                $field = $databaseColumnMapper->getName();
                // only set rule for changed columns if $skipUniqueForUnchanged is set true
                if ( !($skipUniqueForUnchanged && $this->inputData[$field] == $record[$field]) ) {
                    $this->rule('unique', $field, $databaseColumnMapper, $this);
                }
            }
        }
    }
 }


<?php
declare(strict_types=1);

namespace Infrastructure\Database\DataMappers;

use Infrastructure\Database\Postgres;
use Infrastructure\Database\Queries\QueryBuilder;

class ColumnMapper
{
    /** @var  */
    private $tableMapper;

    /** @var string */
    private $name;

    /** @var string postgres column type */
    private $type;

    /** @var bool */
    private $isNullable;

    /**
    * @var bool
    * true if column has a unique constraint
    */
    private $isUnique;

    private $constraints;

    /** @var string */
    private $defaultValue;

    /** @var string|null (if does not apply) */
    private $characterMaximumLength;

    /** @var string */
    private $udtName;

    /** @var  array only applies to enum (USER-DEFINED) types */
    private $enumOptions;

    function __construct(TableMapper $tableMapper,  array $columnInfo)
    {
        $this->constraints = []; // initialize
        $this->enumOptions = []; // initialize
        $this->tableMapper = $tableMapper;
        $this->name = $columnInfo['column_name'];
        $this->type = $columnInfo['data_type'];
        $this->isNullable = $columnInfo['is_nullable'] == 'YES';
        $this->udtName = $columnInfo['udt_name'];
        $this->isUnique = $columnInfo['is_unique'];
        if ($this->isUnique) {
            $this->addConstraint('unique');
        }
        $this->setEnumOptions();
        $this->setDefaultValue($columnInfo['column_default']);
        $this->characterMaximumLength = $columnInfo['character_maximum_length'];
    }

    // make public since ORM does not sniff out every constraint, some must be added manually when table mapper is extended
    // context can be bool or particular string/value related to constraint
    public function addConstraint(string $constraint, $context = true)
    {
        $this->constraints[$constraint] = $context;
    }

    /** input can be null */
    private function setDefaultValue($columnDefault)
    {
        if (is_null($columnDefault)) {
            $this->defaultValue = '';
        } else {
            switch ($this->type) {
                case 'character':
                case 'character varying':
                case 'text':
                    // formatted like 'default'::text
                case 'USER-DEFINED':
                    // formatted like 'default'::tableName_columnName
                    // parse out default
                    $parseColumnDefault = explode("'", $columnDefault);
                    $this->defaultValue = $parseColumnDefault[1];
                    break;
                case 'boolean':
                    // overwrite to be congruous with a postgres value returned from select
                    if ($columnDefault == 'true') {
                        $this->defaultValue = Postgres::BOOLEAN_TRUE;
                    }
                    break;
                default:
                    $this->defaultValue = $columnDefault;
            }
        }
    }

    public function setEnumOptions()
    {
        if ($this->type == 'USER-DEFINED') {
            $q = new QueryBuilder("SELECT e.enumlabel as enum_value FROM pg_type t JOIN pg_enum e on t.oid = e.enumtypid JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace WHERE t.typname = $1", $this->udtName);
            $qResult = $q->execute();
            if (pg_num_rows($qResult) == 0) {
                throw new \Exception("No values for enum field $this->name");
            }
            while ($row = pg_fetch_assoc($qResult)) {
                $this->enumOptions[] = $row['enum_value'];
            }
        }
    }

    /**
     * @return bool
     * not to be confused with column type numeric, it can be any type in $this->numericTypes
     */
    public function isNumericType(): bool
    {
        return in_array($this->type, Postgres::NUMERIC_TYPES);
    }

    /**
     * @return bool
     */
    public function isIntegerType(): bool
    {
        return in_array($this->type, Postgres::INTEGER_TYPES);
    }

    public function isPrimaryKey()
    {
        return $this->name == $this->tableMapper->getPrimaryKeyColumnName();
    }

    public function isBoolean(): bool
    {
        return ($this->type == 'boolean');
    }

    // getters

    public function getName(): string
    {
        return $this->name;
    }

    public function getType(): string
    {
        return $this->type;
    }

    public function getIsNullable(): bool
    {
        return $this->isNullable;
    }

    public function getConstraints(): array
    {
        return $this->constraints;
    }

    public function getIsUnique(): bool
    {
        return $this->getConstraint('unique');
    }

    public function getCharacterMaximumLength()
    {
        return $this->characterMaximumLength;
    }

    public function getUdtName(): string
    {
        return $this->udtName;
    }

    public function getDefaultValue()
    {
        return $this->defaultValue;
    }

    public function getEnumOptions(): array
    {
        return $this->enumOptions;
    }

    public function getConstraint(string $constraint): bool
    {
        return in_array($constraint, $this->constraints);
    }

    public function recordExistsForValue($value): bool
    {
        $q = new QueryBuilder("SELECT ".$this->tableMapper->getPrimaryKeyColumnName()." FROM ".$this->tableMapper->getTableName()." WHERE $this->name = $1", $value);
        return (bool) $q->getOne();
    }
}


<?php
declare(strict_types=1);

namespace Entities\Roles\Model;

use Infrastructure\BaseEntity\BaseMVC\Model\ListViewModels;

// model
class Role implements ListViewModels
{
    private $id;
    private $roleName;
    private $created;

    public function __construct(int $id, string $roleName, \DateTimeImmutable $created)
    {
        $this->id = $id;
        $this->roleName = $roleName;
        $this->created = $created;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getRoleName(): string
    {
        return $this->roleName;
    }

    public function getCreated(): \DateTimeImmutable
    {
        return $this->created;
    }

    /** returns array of list view fields [fieldName => fieldValue] */
    public function getListViewFields(): array
    {
        return [
            'id' => $this->id,
            'role' => $this->roleName,
            'created' => $this->created->format('Y-m-d'),
        ];
    }

    /** whether model is allowed to be updated */
    public function isUpdatable(): bool
    {
        return (RolesTableMapper::getInstance())->isUpdatable();
    }

    /** whether this model is allowed to be deleted
     *  do not allow roles in use (assigned to administrators) to be deleted
     */
    public function isDeletable(): bool
    {
        $rolesEntityMapper = RolesEntityMapper::getInstance();
        return !$rolesEntityMapper->hasAdministrator($this->id) && !$rolesEntityMapper->hasPermissionAssigned($this->id);
    }

    public function getUniqueId(): ?string
    {
        return (string) $this->id;
    }

    public function isTop(): bool
    {
        return $this->getRoleName() == TOP_ROLE;
    }
}


<?php
declare(strict_types=1);

namespace Entities\Administrators\Model;

use Infrastructure\Database\Postgres;
use Infrastructure\Database\DataMappers\TableMapper;
use Infrastructure\Database\Queries\QueryBuilder;

// fake Singleton with public constructor
final class AdministratorsTableMapper extends TableMapper
{
    const TABLE_NAME = 'administrators';
    const UPDATE_FIELDS = ['name', 'username', 'password', 'active'];

    const ORDER_BY_COLUMN_NAME = 'name';

    public static function getInstance()
    {
        static $instance = null;
        if ($instance === null) {
            $instance = new AdministratorsTableMapper();
        }
        return $instance;
    }

    public function __construct()
    {
        parent::__construct(self::TABLE_NAME, '*', self::ORDER_BY_COLUMN_NAME);
    }

    public function getIdByUsername(string $username): ?int
    {
        $q = new QueryBuilder("SELECT id FROM ".self::TABLE_NAME." WHERE username = $1", $username);
        if (null === $id = $q->getOne()) {
            return null;
        }
        return (int) $id;
    }

    // returns hashed password for insert/update
    public function getHashedPassword(string $password): string
    {
        return password_hash($password, PASSWORD_DEFAULT);
    }

    public function callInsert(string $name, string $username, string $passwordClear, bool $active): int
    {
        $columnValues = [
            'name' => $name,
            'username' => $username,
            'password_hash' => $this->getHashedPassword($passwordClear),
            'active' => Postgres::convertBoolToPostgresBool($active),
        ];

        return (int) parent::insert($columnValues);
    }

    /** deletes the administrators record */
    public function delete(int $administratorId): ?string
    {
        return parent::deleteByPrimaryKey($administratorId, 'username');
    }

    /** note this allows any fields to be passed in changedFields and returns only changes from the UPDATE_FIELDS array */
    public function getChangedFields(array $changedFields): array
    {
        $changedAdministratorFields = [];

        foreach ($changedFields as $fieldName => $fieldInfo) {
            if (in_array($fieldName, self::UPDATE_FIELDS)) {
                switch($fieldName) {
                    case 'password':
                        $changedAdministratorFields['password_hash'] = $this->getHashedPassword($changedFields['password']);
                        break;

                    case 'active':
                        $changedAdministratorFields['active'] = Postgres::convertBoolToPostgresBool($changedFields['active']);
                        break;

                    default:
                        $changedAdministratorFields[$fieldName] = $changedFields[$fieldName];
                }
            }
        }

        return $changedAdministratorFields;
    }
}


<?php
declare(strict_types=1);

namespace Entities\Permissions\Model;

use Infrastructure\BaseEntity\BaseMVC\Model\ListViewModels;
use Infrastructure\Database\Postgres;
use Infrastructure\Functions;

/** model */
class Permission implements ListViewModels
{
    /** @var int */
    private $id;

     /** @var string */
    private $title;

    /** @var string|null */
    private $description;

    /** @var bool */
    private $active;

    /** @var \DateTimeImmutable */
    private $created;

    /** @var \SlimPostgres\Administrators\Roles[] an array of assigned role objects */
    private $roles;

    /** @var int[] an array of assigned role ids */
    private $roleIds;

    /** @var string[] an array of basic system permissions which should not be deleted or changed */
    const UNDELETABLE = [
        EVENTS_VIEW_RESOURCE,
        ADMINISTRATORS_VIEW_RESOURCE,
        ADMINISTRATORS_INSERT_RESOURCE,
        ADMINISTRATORS_UPDATE_RESOURCE,
        ADMINISTRATORS_DELETE_RESOURCE,
        ROLES_VIEW_RESOURCE,
        ROLES_INSERT_RESOURCE,
        ROLES_UPDATE_RESOURCE,
        ROLES_DELETE_RESOURCE,
        PERMISSIONS_VIEW_RESOURCE,
        PERMISSIONS_INSERT_RESOURCE,
        PERMISSIONS_UPDATE_RESOURCE,
        PERMISSIONS_DELETE_RESOURCE,
    ];

    public function __construct(int $id, string $title, ?string $description, bool $active, \DateTimeImmutable $created, array $roles)
    {
        // validate roles array is array of role objects
        if (count($roles) == 0) {
            throw new \InvalidArgumentException("Roles cannot be empty for permission (id $id)");
        }
        foreach ($roles as $role) {
            if (get_class($role) != 'Entities\Roles\Model\Role') {
                throw new \InvalidArgumentException("Invalid role in roles");
            }
        }
        $this->id = $id;
        $this->title = $title;
        $this->description = $description;
        $this->active = $active;
        $this->created = $created;
        $this->roles = $roles;
        $this->setRoleIds();
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getTitle(): string
    {
        return $this->title;
    }

    public function getdescription(): ?string
    {
        return $this->description;
    }

    public function getActive(): bool
    {
        return $this->active;
    }

    public function getCreated(): DateTime
    {
        return $this->created;
    }

    /** returns array of list view fields [fieldName => fieldValue] */
    public function getListViewFields(): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'roles' => $this->getRolesString(),
            'active' => Postgres::convertBoolToPostgresBool($this->active), // send 't' / 'f'
            'created' => $this->created->format('Y-m-d'),
        ];
    }

    public function getRoles(): array
    {
        return $this->roles;
    }

    public function getRoleIds(): array
    {
        return $this->roleIds;
    }

    private function setRoleIds()
    {
        $roleIds = [];
        foreach ($this->roles as $role) {
            $roleIds[] = $role->getid();
        }
        $this->roleIds = $roleIds;
    }

    public function hasRole(int $roleId): bool
    {
        return in_array($roleId, $this->roleIds);
    }

    public function getRolesString(): string
    {
        $rolesString = "";
        foreach ($this->roles as $role) {
            $rolesString .= $role->getRoleName().", ";
        }
        $rolesString = Functions::removeLastCharsFromString($rolesString, 2);
        return $rolesString;
    }

    /** whether model is allowed to be updated */
    public function isUpdatable(): bool
    {
        return (PermissionsTableMapper::getInstance())->isUpdatable();
    }

    /** whether this model is allowed to be deleted */
    public function isDeletable(): bool
    {
        if (in_array($this->title, self::UNDELETABLE)) {
            return false;
        }
        return (PermissionsTableMapper::getInstance())->isDeletable();
    }

    public function getUniqueId(): ?string
    {
        return (string) $this->id;
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Database\DataMappers;

use Infrastructure\Database\DataMappers\ListViewMappers;
use Infrastructure\Database\Postgres;
use Infrastructure\Database\Queries\InsertBuilder;
use Infrastructure\Database\Queries\InsertUpdateBuilder;
use Infrastructure\Database\Queries\QueryBuilder;
use Infrastructure\Database\Queries\SelectBuilder;
use Infrastructure\Database\Queries\UpdateBuilder;
use Exceptions;

class TableMapper implements ListViewMappers
{
    /** @var string  */
    protected $tableName;

    /** @var  array of column mapper objects */
    protected $columns;

    /** @var array of columnNames */
    protected $columnNames;

    /** @var string or null if no primary key column exists */
    protected $primaryKeyColumnName;

    protected $orderByColumnName;

    /** @var bool  */
    protected $orderByAsc;

    /**
     * @var array of columnNames with UNIQUE constraint or index
     * NOTE this does not handle multi-column unique constraints
     */
    private $uniqueColumnNames;

    private $uniqueColumns;

    protected $defaultSelectColumnsString;

    public function __construct(string $tableName, $defaultSelectColumnsString = "*", ?string $orderByColumnName = null, bool $orderByAsc = true)
    {
        $this->tableName = $tableName;
        $this->primaryKeyColumnName = null; // initialize

        $this->uniqueColumns = [];
        $this->uniqueColumnNames = [];

        // $this->primaryKeyColumnName will be updated if exists
        // $this->uniqueColumnNames added (then used to set $column->isUnique
        $this->setConstraints();

        $this->defaultSelectColumnsString = $defaultSelectColumnsString;

        if ($orderByColumnName != null) {
            $this->orderByColumnName = $orderByColumnName;
        } elseif ($this->primaryKeyColumnName != null) {
            $this->orderByColumnName = $this->primaryKeyColumnName;
        } else {
            $this->orderByColumnName = null;
        }
        $this->orderByAsc = $orderByAsc;

        // $this->uniqueColumns added
        $this->setColumns();
    }

    /** note this will set uniqueColumnNames whether they are set as a constraint or an index */
    private function setConstraints()
    {
        $q = new QueryBuilder("SELECT ccu.column_name, tc.constraint_type FROM INFORMATION_SCHEMA.constraint_column_usage ccu JOIN information_schema.table_constraints tc ON ccu.constraint_name = tc.constraint_name WHERE tc.table_name = ccu.table_name AND ccu.table_name = $1", $this->tableName);
        $qResult = $q->execute();
        while ($qRow = pg_fetch_assoc($qResult)) {
            switch($qRow['constraint_type']) {
                case 'PRIMARY KEY':
                    $this->primaryKeyColumnName = $qRow['column_name'];
                    break;
                case 'UNIQUE':
                    $this->uniqueColumnNames[] = $qRow['column_name'];
            }
        }
    }

    protected function setColumns()
    {
        try {
            $rs = Postgres::getTableMetaData($this->tableName);
        } catch (\Exception $e) {
            throw new \Exception("Unable to set columns for table $this->tableName");
        }

        $this->columnNames = [];

        while ($columnInfo = pg_fetch_assoc($rs)) {
            $columnInfo['is_unique'] = in_array($columnInfo['column_name'], $this->uniqueColumnNames);
            $c = new ColumnMapper($this, $columnInfo);
            $this->columns[] = $c;
            $this->columnNames[] = $columnInfo['column_name'];
            if ($columnInfo['is_unique']) {
                $this->uniqueColumns[] = $c;
            }
        }
    }

    public function getListViewTitle(): string
    {
        return $this->getFormalTableName();
    }

    public function getInsertTitle(): string
    {
        return "Insert ".$this->getFormalTableName(false);
    }

    // make protected since ORM does not sniff out every constraint, some must be added manually when table mapper is extended
    protected function addColumnConstraint(ColumnMapper $column, string $constraint, $context = true)
    {
        $column->addConstraint($constraint, $context);
    }

    protected function addColumnNameConstraint(string $columName, string $constraint)
    {
        $this->addColumnConstraint($this->getColumnByName($columName), $constraint);
    }

    public function getColumnConstraints(): array
    {
        $constraints = [];
        foreach($this->columns as $column) {
            $constraints[$column->getName()] = $column->getConstraints();
        }
        return $constraints;
    }

    public function getDefaultSelectColumnsString(): string
    {
        return $this->defaultSelectColumnsString;
    }

    /** returns either array of rows or null */
    public function select(?string $columns = null, ?array $whereColumnsInfo = null, ?string $orderBy = null): ?array
    {
        $columns = $columns ?? $this->defaultSelectColumnsString;
        $orderBy = $orderBy ?? $this->getOrderBy();
        $q = new SelectBuilder("SELECT $columns", "FROM $this->tableName", $whereColumnsInfo, $orderBy);
        $pgResult = $q->execute();
        if (!$results = pg_fetch_all($pgResult)) {
            $results = null;
        }
        pg_free_result($pgResult);
        return $results;
    }

    protected function getOrderBy(): ?string
    {
        if ($this->orderByColumnName != null) {
            $orderByString = "$this->orderByColumnName";
            if (!$this->orderByAsc) {
                $orderByString .= " DESC";
            }
            return $orderByString;
        }
        return null;
    }

    public function hasColumnValue(ColumnMapper $databaseColumnMapper, $value): bool
    {
        $q = new QueryBuilder("SELECT ".$this->getPrimaryKeyColumnName()." FROM $this->tableName WHERE ".$databaseColumnMapper->getName()." = $1", [$value]);
        return (bool) $q->getOne();
    }

    /** unlike select(), this returns only the array for a single record, or null */
    public function selectForPrimaryKey($primaryKeyValue, string $columns = "*"): ?array
    {
        if (is_null($this->getPrimaryKeyColumnName())) {
            throw new \Exception("$this->tableName primary key not defined");
        }
        $where = [
            $this->getPrimaryKeyColumnName() => [
                'operators' => ['='],
                'values' => [$primaryKeyValue],
            ],
        ];
        if (null !== $records = $this->select($columns, $where)) {
            return $records[0];
        }
        return null;
    }

    public function getBooleanColumnNames(): array
    {
        $booleanFieldNames = [];
        foreach ($this->columns as $column) {
            if ($column->isBoolean()) {
                $booleanFieldNames[] = $column->getName();
            }
        }
        return $booleanFieldNames;
    }

    /** inserts columnName => 'f' into columnValues array for any boolean column in the table that does not exist in columnValues */
    private function addBooleanColumnFalse(array $columnValues): array
    {
        foreach ($this->getBooleanColumnNames() as $booleanColumnName) {
            if (!isset($columnValues[$booleanColumnName])) {
                $columnValues[$booleanColumnName] = Postgres::BOOLEAN_FALSE;
            }
        }

        return $columnValues;
    }

    /** returns primary key if set, otherwise returns pg result */
    public function insert(array $columnValues, bool $addBooleanColumnFalse = false)
    {
        if ($addBooleanColumnFalse) {
            $columnValues = $this->addBooleanColumnFalse($columnValues);
        }
        $ib = new InsertBuilder($this->tableName);
        if ($this->getPrimaryKeyColumnName() !== null) {
            $ib->setPrimaryKeyName($this->getPrimaryKeyColumnName());
        }
        $this->addColumnsToBuilder($ib, $columnValues);
        return $ib->runExecute();
    }

    public function getChangedColumnsValues(array $inputValues, array $record): array
    {
        $changedColumns = [];
        foreach ($inputValues as $columnName => $value) {
            // throw out any new values that are not table columns
            if ($this->getColumnByName($columnName) !== null && $value != $record[$columnName]) {
                $changedColumns[$columnName] = $value;
            }
        }
        return $changedColumns;
    }

    /**
     * @param array $columnValues
     * @param $primaryKeyValue
     * @param bool $getChangedValues :: default true. if true calls getChangedColumnsValues in order to send only changed to update builder, otherwise all $columnValues is sent to update builder. set false if input only includes changed values in order to not duplicate checking for changes.
     * @param array $record :: best to include if $getChangedValues is true in order to not duplicate select query
     * @param bool $addBooleanColumnFalse if true calls method which adds in boolean columns that don't exist in the input columnValues
     */
    public function updateByPrimaryKey(array $columnValues, $primaryKeyValue, bool $getChangedValues = true, array $record = [], bool $addBooleanColumnFalse = false)
    {
        // what if columnValues is empty array, or updatecolumnValues is empty array
        if ($addBooleanColumnFalse) {
            $columnValues = $this->addBooleanColumnFalse($columnValues);
        }

        if ($getChangedValues) {
            if (count($record) == 0) {
                if (null === $record = $this->selectForPrimaryKey($primaryKeyValue)) {
                    throw new Exceptions\QueryResultsNotFoundException("No record for primary key $primaryKeyValue");
                }
            }
            $updateColumnValues = $this->getChangedColumnsValues($columnValues, $record);
        } else {
            $updateColumnValues = $columnValues;
        }

        if (count($updateColumnValues) == 0) {
            throw new \InvalidArgumentException("No changed columns");
        }

        $ub = new UpdateBuilder($this->tableName, $this->getPrimaryKeyColumnName(), $primaryKeyValue);
        $this->addColumnsToBuilder($ub, $updateColumnValues);
        $dbResult = $ub->runExecute();

        if (pg_affected_rows($dbResult) == 0) {
            throw new Exceptions\QueryResultsNotFoundException();
        }
    }

    /**
     * if row is not deleted (ie primary key value not found), an exception is thrown
     * note that $primaryKeyValue argument is untyped as it can be string or int
    */
    public function deleteByPrimaryKey($primaryKeyValue, ?string $returnField = null): ?string
    {
        $query = "DELETE FROM $this->tableName WHERE ".$this->getPrimaryKeyColumnName()." = $1";
        $q = new QueryBuilder($query, $primaryKeyValue);

        if ($returnField !== null) {
            return $q->executeWithReturnField($returnField);
        }

        $dbResult = $q->execute();

        if (pg_affected_rows($dbResult) == 0) {
            throw new Exceptions\QueryResultsNotFoundException();
        }

        return null;
    }

    private function addColumnsToBuilder(InsertUpdateBuilder $builder, array $columnValues)
    {
        foreach ($columnValues as $name => $value) {
            /** make sure this is truly a column */
            if (null !== $column = $this->getColumnByName($name)) {
                if (is_string($value) && mb_strlen($value) == 0) {
                    $value = $this->handleBlankValue($column);
                }
                $builder->addColumn($name, $value);
            } else {
                throw new \InvalidArgumentException("Database table ".$this->tableName." does not contain a $name column");
            }
        }
    }

    private function handleBlankValue(ColumnMapper $column)
    {
        // set to null if field is nullable
        if ($column->getIsNullable()) {
            return null;
        }

        // set to 0 if field is numeric
        if ($column->isNumericType()) {
            return 0;
        }

        // set to f if field is boolean
        if ($column->isBoolean()) {
            return Postgres::BOOLEAN_FALSE;
        }

        return '';
    }

    // getters

    /**
     * @param bool $plural - if false last character is removed
     * @return string
     */
    public function getFormalTableName(bool $plural = true): string
    {
        $name = ucwords(str_replace('_', ' ', $this->tableName));
        if (!$plural) {
            $name = substr($name, 0, mb_strlen($this->tableName) - 1);
        }
        return $name;
    }

    public function getTableName(): string
    {
        return $this->tableName;
    }

    public function getPrimaryKeyColumnName(): ?string
    {
        return $this->primaryKeyColumnName;
    }

    public function getUpdateColumnName(): ?string
    {
        return $this->getPrimaryKeyColumnName();
    }

    public function getOrderByColumnName(): ?string
    {
        return $this->orderByColumnName;
    }

    public function getListViewSortColumn(): ?string
    {
        return $this->getOrderByColumnName();
    }

    public function getListViewSortAscending(): bool
    {
        return $this->getOrderByAsc();
    }

    public function getOrderByAsc(): bool
    {
        return $this->orderByAsc;
    }

    public function getColumns(): array
    {
        if (count($this->columns) == 0) {
            throw new \Exception('No columns in table '.$this->tableName);
        }
        return $this->columns;
    }

    public function getColumnNames(): array
    {
        return $this->columnNames;
    }

    public function getColumnByName(string $columnName): ?ColumnMapper
    {
        foreach ($this->columns as $column) {
            if ($column->getName() == $columnName) {
                return $column;
            }
        }

        return null;
    }

    public function getUniqueColumnNames(): array
    {
        return $this->uniqueColumnNames;
    }

    public function getUniqueColumns(): array
    {
        return $this->uniqueColumns;
    }

    public function getCountSelectColumns(): int
    {
        if ($this->defaultSelectColumnsString == '*') {
            return count($this->columns);
        }

        return count(explode(",", $this->defaultSelectColumnsString));
    }
}


<?php
declare(strict_types=1);

/** note: this file can also be called for cli scripts.*/

namespace Infrastructure;

use Dotenv\Dotenv;
use Infrastructure\Database\Postgres;
use Infrastructure\Security\Authentication\AuthenticationService;
use Infrastructure\Security\Authorization\AuthorizationService;
use Infrastructure\Security\CsrfMiddleware;
use Infrastructure\Utilities\TrackerMiddleware;
use Entities\Events\EventsTableMapper;
use Infrastructure\Utilities;
use Infrastructure\Functions;
use Slim\Http\Request;
use Slim\Http\Response;

class SlimPostgres
{
    private $config;

    /** @var array config settings that get added to slim so framework can access through container */
    private $commonConfigSettingsKeys;

    private $environmentalVariables;
    private $database;
    private $eventsTableMapper;
    private $mailer;

    /** session variable keys */

    const SESSION_KEY_LAST_ACTIVITY = 'lastActivity';

    /** front end notifications */
    const SESSION_KEY_NOTICE = 'notice';

    /** administrative notifications */
    const SESSION_KEY_ADMIN_NOTICE = 'adminNotice';

    /** administrative resource to load upon login */
    const SESSION_KEY_GOTO_ADMIN_PATH = 'gotoAdminPath';

    /** tracks number of login failures */
    const SESSION_KEY_NUM_FAILED_LOGINS = 'numFailedLogins';

    /** filter field values for admin list views, stored in session in order to keep filter on through multiple requests */
    const SESSION_KEY_ADMIN_LIST_VIEW_FILTER = 'adminListViewFilter';

    /** info concerning logged on administrator */
    const SESSION_KEY_ADMINISTRATOR_ID = 'administratorId';

    /** frontend notice statuses (can be used as css classes) */
    const STATUS_NOTICE_SUCCESS = 'noticeSuccess';
    const STATUS_NOTICE_FAILURE = 'noticeFailure';
    const STATUS_NOTICE_CAUTION = 'noticeCaution';
    const STATUS_NOTICE_MUTED = 'noticeMuted';

    /** admin notice statuses (can be used as css classes) */
    const STATUS_ADMIN_NOTICE_SUCCESS = 'adminNoticeSuccess';
    const STATUS_ADMIN_NOTICE_FAILURE = 'adminNoticeFailure';
    const STATUS_ADMIN_NOTICE_CAUTION = 'adminNoticeCaution';
    const STATUS_ADMIN_NOTICE_MUTED = 'adminNoticeMuted';

    const VALID_ROUTE_TYPES = ['index', 'index.reset', 'insert', 'update', 'delete'];

    /** args array key so controllers can pass user input data to views */
    const USER_INPUT_KEY = 'userInput';

    public function __construct()
    {
        $this->commonConfigSettingsKeys = ['isLive', 'businessName', 'businessDba', 'emails', 'authentication', 'authorization', 'trimAllUserInput', 'databaseTables'];
        $dotenv = new Dotenv(APPLICATION_ROOT_DIRECTORY);
        $dotenv->load();

        $this->environmentalVariables = getenv();

        /** validate .env (note, not thorough validation) */
        $dotenv->required('PHPMAILER_PROTOCOL')->allowedValues(['', 'smtp', 'sendmail', 'mail', 'qmail']);
        /** explicitly set to null if blank */
        $phpMailerProtocol = trim($this->environmentalVariables['PHPMAILER_PROTOCOL']) == '' ?null : $this->environmentalVariables['PHPMAILER_PROTOCOL'];

        if ($phpMailerProtocol == 'smtp') {
            $dotenv->required('PHPMAILER_SMTP_HOST');
            $dotenv->required('PHPMAILER_SMTP_PORT')->isInteger();
        }

        $this->config = require APPLICATION_ROOT_DIRECTORY . '/config/settings.php';

        if (isset($this->config['emails'])) {
            foreach ($this->config['emails'] as $emailRole => $email) {
                if (isset($this->environmentalVariables['EMAILS_'.strtoupper($emailRole)])) {
                    $this->config['emails'][$emailRole] = $this->environmentalVariables['EMAILS_'.strtoupper($emailRole)];
                }
            }
        }

        /** so no need to set encoding for each mb_strlen() call */
        mb_internal_encoding($this->config['mbInternalEncoding']);

        /** add some .env to config */
        // assume this is a live server unless IS_LIVE env var set false
        $this->config['isLive'] = !(array_key_exists('IS_LIVE', $this->environmentalVariables) && $this->environmentalVariables['IS_LIVE'] === "0");

        // echo errors on dev sites unless env var ERRORS_ECHO_DEV set false
        $this->config['errors']['echoDev'] = !(array_key_exists('ERRORS_ECHO_DEV', $this->environmentalVariables) && $this->environmentalVariables['ERRORS_ECHO_DEV'] === "0");

        // do not email error notifications on dev sites unless env var ERRORS_EMAIL_DEV set true
        $this->config['errors']['emailDev'] = array_key_exists('ERRORS_EMAIL_DEV', $this->environmentalVariables) && $this->environmentalVariables['ERRORS_EMAIL_DEV'] === "1";

        /** if protocol is set, set up emailer, which is used in error handler and container */
        if ($phpMailerProtocol !== null) {
            $phpMailerSmtpHost = (array_key_exists('PHPMAILER_SMTP_HOST', $this->environmentalVariables)) ? $this->environmentalVariables['PHPMAILER_SMTP_HOST'] : null;
            $phpMailerSmtpPort = (array_key_exists('PHPMAILER_SMTP_PORT', $this->environmentalVariables)) ? (int) $this->environmentalVariables['PHPMAILER_SMTP_PORT'] : null;
            $disableMailerSend = !$this->config['isLive'] && !$this->config['errors']['emailDev'];
            $this->mailer = new Utilities\PhpMailerService(
                $this->config['errors']['phpErrorLogPath'],
                $this->config['emails']['service'],
                $this->config['businessName'],
                $phpMailerProtocol,
                $phpMailerSmtpHost,
                $phpMailerSmtpPort,
                $disableMailerSend
            );
        }

        /** error handling */

        /** only echo on dev sites */
        $echoErrors = !$this->config['isLive'] && $this->config['errors']['echoDev'];

        $emailErrors = $this->mailer !== null && ($this->config['isLive'] || $this->config['errors']['emailDev']);
        $emailErrorsTo = [];
        if (array_key_exists('emailTo', $this->config['errors'])) {
            foreach ($this->config['errors']['emailTo'] as $roleEmail) {
                $emailErrorsTo[] = $this->config['emails'][$roleEmail];
            }
        }

        $errorHandler = new Utilities\ErrorHandler(
            $this->config['errors']['phpErrorLogPath'],
            $this->getRedirect(),
            $echoErrors,
            $emailErrors,
            $emailErrorsTo,
            $this->mailer,
            $this->config['errors']['fatalMessage']
        );

        /** workaround for catching some fatal errors like parse errors. note that parse errors in this file and index.php are not handled, but cause a fatal error with display (not displayed if display_errors is off in php.ini, but the ini_set call will not affect it). */
        register_shutdown_function(array($errorHandler, 'shutdownFunction'));
        set_error_handler(array($errorHandler, 'phpErrorHandler'));
        set_exception_handler(array($errorHandler, 'throwableHandler'));

        /**  all, including future types */
        error_reporting( -1 );

        /** do not have php display errors, since this will be determined by config and done in error handler */
        ini_set( 'display_errors', 'off' );
        ini_set( 'display_startup_errors', 'off' );

        /**
         * any errors prior to this point will not be logged
         * even though the error handler logs errors, this ensures errors in the error handler itself or in this file after this point will be logged. note, if using slim error handling, this will log all php errors
         */
        ini_set('error_log', $this->config['errors']['phpErrorLogPath']);

        /**
         * set up and connect to postgres, which is used in error handler and container
         * this is done after setting error handler in case connection fails
         * note, injected to error handler below
         */
        $postgresConnectionString = (array_key_exists('POSTGRES_CONNECTION_STRING', $this->environmentalVariables)) ? $this->environmentalVariables['POSTGRES_CONNECTION_STRING'] : '';
        $postgres = Postgres::getInstance($postgresConnectionString);

        /** used in error handler and container */
        $this->eventsTableMapper = EventsTableMapper::getInstance();

        if ($this->config['errors']['logToDatabase']) {
            $errorHandler->setEventsTableMapper($this->eventsTableMapper);
        }

        if (!Functions::isRunningFromCommandLine()) {
            /**
             * verify/force all pages to be https. and verify/force www or not www based on Config::useWww
             * if not, REDIRECT TO PROPER SECURE PAGE
             * note this practice is ok:
             * http://security.stackexchange.com/questions/49645/actually-isnt-it-bad-to-redirect-http-to-https
             */
            if ( ($this->environmentalVariables['REDIRECT_TO_HTTPS'] !== "0" && !$this->isHttps()) || ($this->config['domainUseWww'] && !$this->isWww()) || (!$this->config['domainUseWww'] && $this->isWww())) {
                $this->redirect();
            }

            /** SESSION */
            $sessionTTLseconds = $this->config['session']['ttlHours'] * 60 * 60;
            ini_set('session.gc_maxlifetime', (string) $sessionTTLseconds);
            ini_set('session.cookie_lifetime', (string) $sessionTTLseconds);
            if (isset($this->config['session']['savePath']) && mb_strlen($this->config['session']['savePath']) > 0) {
                session_save_path($this->config['session']['savePath']);
            }
            session_start();

            /** update last activity time stamp */
            $_SESSION[self::SESSION_KEY_LAST_ACTIVITY] = time();
        }

    }

    public function run()
    {
        $slim = new \Slim\App($this->getSlimSettings());
        $slimContainer = $slim->getContainer();

        $this->setSlimDependences($slimContainer, $this->eventsTableMapper, $this->mailer);

        $this->removeSlimErrorHandler($slimContainer);

        $this->setSlimMiddleware($slim, $slimContainer);

        $this->registerSlimRoutes($slim, $slimContainer);

        $slim->run();
    }

    private function getSlimSettings(): array
    {
        $slimSettings['settings'] = $this->config['slim'];

        /** add common config settings */
        foreach ($this->commonConfigSettingsKeys as $key) {
            if (isset($this->config[$key])) {
                $slimSettings['settings'][$key] = $this->config[$key];
            }
        }

        /** Override the default Not Found Handler */
        $slimSettings['notFoundHandler'] = function ($container) {
            return function (Request $request, Response $response) use ($container) {

                /** log error */
                $this->eventsTableMapper->insertNotice(EVENT_PAGE_NOT_FOUND);

                $_SESSION[SlimPostgres::SESSION_KEY_NOTICE] = [$this->config['pageNotFoundText'], SlimPostgres::STATUS_NOTICE_FAILURE];
                return $container->view->render(
                    $response,
                    'frontend/home.php',
                    [])->withStatus(404);
            };
        };

        return $slimSettings;
    }

    private function setSlimDependences($container, EventsTableMapper $eventsTableMapper, ?Utilities\PhpMailerService $mailer)
    {
        /** Template */
        $container['view'] = function ($container) {
            $settings = $container->get('settings');
            $templateVariables = [
                'businessName' => $settings['businessName'],
                'domainName' => $settings['domainName'],
                'businessDba' => $settings['businessDba'],
                'isLive' => $settings['isLive'],
                'authentication' => $container->authentication,
                'authorization' => $container->authorization,
                'router' => $container->router,
                'csrfNameKey' => $container->csrf->getTokenNameKey(),
                'csrfName' => $container->csrf->getTokenName(),
                'csrfValueKey' => $container->csrf->getTokenValueKey(),
                'csrfValue' => $container->csrf->getTokenValue(),
            ];
            return new \Slim\Views\PhpRenderer($settings['templatesPath'], $templateVariables);
        };

        /** Authentication */
        $container['authentication'] = function($container) {
            $settings = $container->get('settings');
            return new AuthenticationService($settings['authentication']['maxFailedLogins'], $settings['authentication']['administratorHomeRoutes']);
        };

        /** Authorization */
        $container['authorization'] = function($container) {
            $settings = $container->get('settings');
            return new AuthorizationService();
        };

        /** Events (Database Log) */
        $container['events'] = function($container) use ($eventsTableMapper) {
            return $eventsTableMapper;
        };

        /** Mailer */
        $container['mailer'] = function($container) use ($mailer) {
            return $mailer;
        };

        /** CSRF */
        $container['csrf'] = function ($container) {
            /** cannot directly pass null because received by reference */
            $storage = null;

            /**
             * setting the persistentTokenMode parameter true allows redisplaying a form with errors with a render rather than redirect call and will not cause CSRF failure if the page is refreshed
             * (http://blog.ircmaxell.com/2013/02/preventing-csrf-attacks.html)
             */
            $guard = new \Slim\Csrf\Guard('csrf', $storage, null, 200, 16, true);
            $guard->setFailureCallable(function (Request $request, Response $response, $next) {
                $request = $request->withAttribute("csrf_status", false);
                return $next($request, $response);
            });
            return $guard;
        };
    }

    private function removeSlimErrorHandler($slimContainer)
    {
        unset($slimContainer['errorHandler']);
        unset($slimContainer['phpErrorHandler']);
    }

    /** Global middleware registration */
    private function setSlimMiddleware(\Slim\App $slim, $slimContainer)
    {
        /** handle CSRF check failures and allow template to access and insert CSRF fields to forms */
        $slim->add(new CsrfMiddleware($slimContainer));

        /** Insert System Event for every resource request */
        if (isset($this->config['trackAll']) && $this->config['trackAll']) {
            $slim->add(new TrackerMiddleware($slimContainer));
        }

        /** slim CSRF check middleware */
        $slim->add($slimContainer->csrf);
    }

    /** note need the arguments for routes.php to access */
    private function registerSlimRoutes(\Slim\App $slim, $slimContainer)
    {
        /** make available to routes file */
        $config = $this->config;
        require APPLICATION_ROOT_DIRECTORY . '/config/routes.php';
    }

    /** if called with no args, redirects to current URI with proper protocol, www or not based on config, and query string */
    private function redirect(string $toURI = null)
    {
        header("Location: ".$this->getRedirect($toURI));
        exit();
    }

    private function getRedirect(string $toURI = null): ?string
    {
        if (is_null($toURI)) {
            if (Functions::isRunningFromCommandLine()) {
                return null;
            }
            $toURI = $this->getCurrentUri(true);
        }

        /** add initial '/' if nec */
        if (substr($toURI, 0, 1) != "/") {
            $toURI = "/" . $toURI;
        }

        return $this->getBaseUrl() . $toURI;
    }

    private function getCurrentUri(bool $includeQueryString = true): string
    {
        $uri = $_SERVER['REQUEST_URI'];
        if ($includeQueryString && isset($_SERVER['QUERY_STRING']) && !empty($_SERVER['QUERY_STRING'])) {
            $uri .= "?" . $_SERVER['QUERY_STRING'];
        }

        return $uri;
    }

    /** if on http and env var set to not redirect, stay on http, otherwise use https */
    private function getProtocol()
    {
        return (!$this->isHttps() && $this->environmentalVariables['REDIRECT_TO_HTTPS'] === "0") ? "http" : "https";
    }

    private function getBaseUrl()
    {
        $baseUrl = $this->getProtocol()."://";
        if ($this->config['domainUseWww']) {
            $baseUrl .= "www.";
        }
        $baseUrl .= $this->getHostWithoutWww();
        return $baseUrl;
    }

    private function getHostWithoutWww(): string
    {
        if (substr($_SERVER['HTTP_HOST'], 0, 4) == 'www.') {
            return substr($_SERVER['HTTP_HOST'], 4);
        }
        return $_SERVER['HTTP_HOST'];
    }

    /**
     * determines if current page is https
     * @return bool
     */
    private function isHttps(): bool
    {
        return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || intval($_SERVER['SERVER_PORT']) === 443;
    }

    /**
     * determines if url host name begins with 'www'
     * @return bool
     */
    private function isWww(): bool
    {
        return (strtolower(substr($_SERVER['SERVER_NAME'], 0, 3)) == 'www');
    }

    /**
     * determines if a $sessionId id is valid.
     * @param $session_id
     * @param bool optional $isEmptyIdValid
     * @return bool
     */
    private function isSessionIdValid(string $sessionId): bool
    {
        // if (mb_strlen($sessionId) == 0) { // if blank, there is no session id
        //     return true;
        // }
        return preg_match('/^[-,a-zA-Z0-9]{1,128}$/', $sessionId) > 0;
    }

    /** because of route naming conventions, only send resourceType of post, put, or patch */
    public static function getRouteName(bool $isAdmin = true, string $routePrefix = null, string $routeType = null, string $requestMethod = null): string
    {
        $routeName = '';

        if ($isAdmin) {
            $routeName .= ROUTEPREFIX_ADMIN;
        }

        if ($routePrefix !== null) {
            $routeName .= '.' . $routePrefix;
        }

        if ($requestMethod !== null) {
            $validActionMethods = ['put', 'post'];
            if (!in_array($requestMethod, $validActionMethods)) {
                throw new \Exception("Invalid request method $requestMethod. Only post and put accepted in route names.");
            }

            $routeName .= '.' . $requestMethod;
        }

        if ($routeType !== null) {
            if (!in_array($routeType, self::VALID_ROUTE_TYPES)) {
                throw new \Exception("Invalid route type $routeType");
            }

            $routeName .= '.' . $routeType;
        }

        return $routeName;
    }

    public static function setAdminNotice(string $text, string $status = 'success')
    {
        $statusValues = ['success', 'failure', 'caution', 'muted'];
        if (!in_array($status, $statusValues)) {
            throw new \InvalidArgumentException("Invalid admin notice status $status");
        }

        $_SESSION[self::SESSION_KEY_ADMIN_NOTICE] = ["$text", 'adminNotice'.ucfirst($status)];
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\DatabaseTable\View;

use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;
use Infrastructure\Database\DataMappers\TableMapper;

// a view class for a single database table, where the table name is an arg of the route functions and is then injected to the parent class. This allows table CRUD without creating table models.
class DatabaseTableViewChild extends DatabaseTableView
{
    protected $routePrefix;
    protected $tableMapper;
    private $tableName;

    public function __construct(Container $container)
    {
        $this->container = $container;
    }

    /** overrides in order to get objects and send to indexView */
    public function routeIndex(Request $request, Response $response, $args)
    {
        $this->tableName = $args[ROUTEARG_DATABASE_TABLE_NAME];
        $this->tableMapper = new TableMapper($this->tableName);
        parent::__construct($this->container, $this->tableMapper, ROUTEPREFIX_ROLES);

        return $this->indexView($response);
    }

    protected function setInsert()
    {
        return null;
        $this->insertLinkInfo = null;
        /** note, no separate authorization for insert/update/delete. if administrator is authorized for crud s/he is good to go */
        // if ()
        $this->insertLinkInfo = ($this->authorization->isAuthorized($this->getResource('insert'))) ? [
            'text' => $this->mapper->getInsertTitle(),
            'route' => SlimPostgres::getRouteName(true, $this->routePrefix, 'insert')
        ] : null;
    }

    protected function setUpdate()
    {
        /** can be null */
        $this->updateColumn = null;

        $this->updatesPermitted = false;

        $this->updateRoute = null;
    }

    protected function setDelete()
    {
        $this->deletesPermitted = false;

        $this->deleteRoute = null;
    }

    // protected function getResource(string $which): string
    // {
    //     switch ($which) {
    //         case 'insert':
    //             return ROLES_INSERT_RESOURCE;
    //             break;
    //         case 'update':
    //             return ROLES_UPDATE_RESOURCE;
    //             break;
    //         case 'delete':
    //             return ROLES_DELETE_RESOURCE;
    //             break;
    //         default:
    //             throw new \InvalidArgumentException("Undefined resource $which");
    //     }
    // }
}