From c37e1ff65431f7003709f48793ce9318f19cab53 Mon Sep 17 00:00:00 2001 From: ArrayIterator Date: Thu, 16 Nov 2023 08:09:32 +0700 Subject: [PATCH] add changes --- Depends/AbstractRepositoryUserDepends.php | 20 + Depends/Option.php | 177 ++++++ Depends/SingleSiteEntity.php | 100 +++ Depends/Sites.php | 101 +++ Entities/Admin.php | 191 +++--- Entities/AdminLog.php | 2 +- Entities/AdminMeta.php | 2 +- Entities/AdminOnlineActivity.php | 6 +- Entities/Attachment.php | 80 --- Entities/Capability.php | 161 +++-- Entities/Options.php | 170 +++++ Entities/Role.php | 85 ++- Entities/RoleCapability.php | 44 +- Entities/Site.php | 373 +++++++++++ Entities/User.php | 175 +++--- Entities/UserAttachment.php | 80 --- Entities/UserLog.php | 2 +- Entities/UserMeta.php | 2 +- Entities/UserOnlineActivity.php | 6 +- Entities/UserTerm.php | 78 ++- Entities/UserTermGroup.php | 3 +- Entities/UserTermGroupMeta.php | 4 +- Entities/UserTermMeta.php | 6 +- Languages/{users-module.pot => module.pot} | 14 +- Route/Attributes/AbstractAPIAttributes.php | 24 + Route/Attributes/Dashboard.php | 63 ++ Route/Attributes/DashboardAPI.php | 12 + Route/Attributes/RouteAPI.php | 73 +++ Route/Attributes/User.php | 12 + Route/Attributes/UserAPI.php | 12 + .../AbstractAdministrationController.php | 57 ++ Route/Controllers/AbstractApiController.php | 68 ++ .../AbstractAuthenticationBasedController.php | 64 ++ .../AbstractDashboardController.php | 23 + Route/Controllers/AbstractUserController.php | 45 ++ Traits/UserModuleAssertionTrait.php | 23 + Traits/UserModuleAuthTrait.php | 382 ++++++++++++ Traits/UserModuleDependsTrait.php | 35 ++ Traits/UserModuleEventTrait.php | 50 ++ Traits/UserModulePermissiveTrait.php | 96 +++ TwigExtensions/UrlExtension.php | 36 ++ Users.php | 588 ++---------------- phpcs.xml | 7 + 43 files changed, 2576 insertions(+), 976 deletions(-) create mode 100644 Depends/AbstractRepositoryUserDepends.php create mode 100644 Depends/Option.php create mode 100644 Depends/SingleSiteEntity.php create mode 100644 Depends/Sites.php delete mode 100644 Entities/Attachment.php create mode 100644 Entities/Options.php create mode 100644 Entities/Site.php delete mode 100644 Entities/UserAttachment.php rename Languages/{users-module.pot => module.pot} (81%) create mode 100644 Route/Attributes/AbstractAPIAttributes.php create mode 100644 Route/Attributes/Dashboard.php create mode 100644 Route/Attributes/DashboardAPI.php create mode 100644 Route/Attributes/RouteAPI.php create mode 100644 Route/Attributes/User.php create mode 100644 Route/Attributes/UserAPI.php create mode 100644 Route/Controllers/AbstractAdministrationController.php create mode 100644 Route/Controllers/AbstractApiController.php create mode 100644 Route/Controllers/AbstractAuthenticationBasedController.php create mode 100644 Route/Controllers/AbstractDashboardController.php create mode 100644 Route/Controllers/AbstractUserController.php create mode 100644 Traits/UserModuleAssertionTrait.php create mode 100644 Traits/UserModuleAuthTrait.php create mode 100644 Traits/UserModuleDependsTrait.php create mode 100644 Traits/UserModuleEventTrait.php create mode 100644 Traits/UserModulePermissiveTrait.php create mode 100644 TwigExtensions/UrlExtension.php diff --git a/Depends/AbstractRepositoryUserDepends.php b/Depends/AbstractRepositoryUserDepends.php new file mode 100644 index 0000000..91bf84a --- /dev/null +++ b/Depends/AbstractRepositoryUserDepends.php @@ -0,0 +1,20 @@ +users->getEntityManager(); + } + + public function determineSite($site, &$argumentValid = null) : ?Site + { + if ($site instanceof Site) { + $argumentValid = true; + if ($site->isPostLoad()) { + return $site; + } + $site = $site->getId(); + if (!is_int($site)) { + return null; + } + } + + $argumentValid = false; + if ($site === null) { + $argumentValid = true; + return $this->users->getSite()->current(); + } + if (is_numeric($site)) { + $site = is_string($site) + && !str_contains($site, '.') + ? (int) $site + : $site; + $argumentValid = is_int($site); + return $argumentValid ? $this + ->users + ->getConnection() + ->findOneBy( + Site::class, + ['id' => $site] + ) : null; + } + if (is_string($site)) { + $argumentValid = true; + return $this + ->users + ->getConnection() + ->findOneBy( + Site::class, + ['domain' => $site] + ); + } + + return null; + } + + /** + * @return ObjectRepository&Selectable + */ + public function getRepository() : ObjectRepository&Selectable + { + return $this + ->users + ->getConnection() + ->getRepository(Options::class); + } + + public function getBatch( + string $name, + ?Site &$site = null, + string ...$optionNames + ): array { + $site = $this->determineSite($site); + $siteId = $site?->getId(); + $optionNames = array_merge([$name], $optionNames); + $optionNames = array_filter($optionNames, 'is_string'); + return $this + ->getRepository() + ->findBy( + [ + 'name' => Expression::in('name', array_values($optionNames)), + 'site_id' => $siteId + ] + ); + } + + private function normalizeSiteId(Site|int|null $site = null) : ?int + { + $site ??= $this->users->getSite(); + return !$site ? null : (is_int($site) ? $site : $site->getId()); + } + + public function getOrCreate( + string $name, + &$siteObject = null, + Site|false|null $site = false + ): ?Options { + $option = $this->get($name, $site, $siteObject); + if (!$option) { + $option = new Options(); + $option->setName($name); + $option->setSiteId($siteObject); + $option->setEntityManager($this->getEntityManager()); + } + return $option; + } + + public function saveBatch( + Options ...$option + ): void { + $em = null; + foreach ($option as $opt) { + $em ??= $opt->getEntityManager()??$this->getEntityManager(); + $em->persist($opt); + } + $em?->flush(); + } + + /** + * @param string $name + * @param null $siteId + * @param ?Sites $site + * @return Options|null + */ + public function get( + string $name, + ?Site &$site = null + ) : ?Options { + $siteId = $this->determineSite($site)?->getId(); + return $this + ->getRepository() + ->findOneBy([ + 'name' => $name, + 'site_id' => $siteId + ]); + } + + public function save(Options $options): void + { + $em = $options->getEntityManager()??$this->getEntityManager(); + $em->persist($options); + $em->flush(); + } + + public function set( + string $name, + mixed $value, + ?bool $autoload = null, + Site|false|null $site = false + ): Options { + $entity = $this->getOrCreate($name, $site); + $entity->setValue($value); + if ($autoload !== null) { + $entity->setAutoload($autoload); + } + $this->save($entity); + + return $entity; + } +} diff --git a/Depends/SingleSiteEntity.php b/Depends/SingleSiteEntity.php new file mode 100644 index 0000000..7f2af2a --- /dev/null +++ b/Depends/SingleSiteEntity.php @@ -0,0 +1,100 @@ +request); + $this->domain = $main??$host; + $this->id = 0; + $this->name = $this->domain; + $this->domain_alias = $alias??$this->domain_alias; + parent::__construct(); + } + + public function getId(): ?int + { + return null; + } + + /** + * @param ServerRequestInterface $request + * @param $match + * @return array + */ + public static function parseHost( + ServerRequestInterface $request, + &$match = null + ): array { + $host = $request->getUri()->getHost(); + preg_match( + '~^(?[^.]+)\.(?.+\.[^.]+)$~', + $host, + $match, + PREG_UNMATCHED_AS_NULL + ); + $mainDomain = $match ? $match['host'] : null; + $subDomain = $match ? $match['subdomain'] : null; + return [ + $mainDomain, + $subDomain, + $host + ]; + } + + public function __set(string $name, $value): void + { + // none + } + + public function setUuid(string $uuid): void + { + } + + public function setDomain(string $domain): void + { + } + + public function setDomainAlias(?string $domain_alias): void + { + } + + public function setUserId(?int $user_id): void + { + } + + public function setStatus(string $status): void + { + } + + public function setDeletedAt(?DateTimeInterface $deleted_at): void + { + } + + public function setUser(?Admin $user): void + { + } + + #[PrePersist] + public function prePersist() + { + throw new RuntimeException( + 'Default entity can not be save' + ); + } +} diff --git a/Depends/Sites.php b/Depends/Sites.php new file mode 100644 index 0000000..a70a785 --- /dev/null +++ b/Depends/Sites.php @@ -0,0 +1,101 @@ +&Selectable + */ + public function getRepository() : ObjectRepository&Selectable + { + return $this + ->users + ->getConnection() + ->getRepository(Site::class); + } + + /** + * @return bool + */ + public function isMultiSite() : bool + { + if ($this->multisite !== null) { + return $this->multisite; + } + + /** + * @var ?Options $obj + */ + $obj = $this + ->users + ->getOption() + ->getRepository() + ->findOneBy([ + 'name' => 'enable_multisite', + 'site_id' => null + ]); + if (!$obj) { + return $this->multisite = false; + } + $value = $obj->getValue(); + $value = is_string($value) + ? strtolower($value) + : $value; + return $this->multisite = $value === 'yes' + || $value === 'true' + || $value === true + || $value === '1'; + } + + /** + * @return ?Site + */ + public function current(): ?Site + { + if ($this->site !== null) { + return $this->site?:null; + } + + if (!$this->isMultiSite()) { + return $this->site = new SingleSiteEntity( + $this->users->getRequest() + ); + } + + [$main, $alias, $host] = SingleSiteEntity::parseHost($this->users->getRequest()); + $expression = [ + Expression::eq('domain', $host) + ]; + if ($main && $alias) { + $expression[] = Expression::andX( + Expression::eq('domain', $main), + Expression::eq('domain_alias', $alias), + ); + } + $where = count($expression) > 1 + ? Expression::orX(...$expression) + : $expression[0]; + $this->site = $this + ->getRepository() + ->matching( + Expression::criteria() + ->where($where) + ->setMaxResults(1) + )->first()?:false; + return $this->site?:null; + } +} diff --git a/Entities/Admin.php b/Entities/Admin.php index 01122eb..33d032e 100644 --- a/Entities/Admin.php +++ b/Entities/Admin.php @@ -3,8 +3,10 @@ namespace ArrayAccess\TrayDigita\App\Modules\Users\Entities; -use ArrayAccess\TrayDigita\Auth\Roles\SuperAdminRole; +use ArrayAccess\TrayDigita\App\Modules\Media\Entities\Attachment; use ArrayAccess\TrayDigita\Database\Entities\Abstracts\AbstractUser; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\HasLifecycleCallbacks; use Doctrine\ORM\Mapping\Index; @@ -14,6 +16,8 @@ use Doctrine\ORM\Mapping\UniqueConstraint; /** + * @property-read ?int $site_id + * @property-read ?Site $site * @property-read ?Attachment $attachment */ #[Entity] @@ -22,24 +26,25 @@ options: [ 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', - 'comment' => 'Administrator users' + 'comment' => 'Administrator users', + 'priority' => 5, ] )] #[UniqueConstraint( - name: 'unique_username', - columns: ['username'] + name: 'unique_username_site_id', + columns: ['username', 'site_id'] )] #[UniqueConstraint( - name: 'unique_email', - columns: ['email'] + name: 'unique_email_site_id', + columns: ['email', 'site_id'] )] #[UniqueConstraint( - name: 'unique_identity_number', - columns: ['identity_number'] + name: 'unique_identity_number_site_id', + columns: ['identity_number', 'site_id'] )] #[Index( - columns: ['username', 'status', 'role', 'first_name', 'last_name'], - name: 'index_username_status_role_first_name_last_name' + columns: ['username', 'status', 'role', 'first_name', 'last_name', 'site_id'], + name: 'index_username_status_role_first_name_last_name_site_id' )] #[Index( columns: ['attachment_id'], @@ -49,78 +54,85 @@ columns: ['role'], name: 'relation_admins_role_roles_identity' )] +#[Index( + columns: ['site_id'], + name: 'relation_admins_site_id_sites_id' +)] #[HasLifecycleCallbacks] class Admin extends AbstractUser { - const TABLE_NAME = 'admins'; - - const ROLE_SUPER_ADMIN = SuperAdminRole::NAME; - // administrator and it was co admin - const ROLE_ADMIN = 'admin'; - const ROLE_PRESIDENT = 'president'; - const ROLE_VICE_PRESIDENT = 'vice_president'; - const ROLE_RECTOR = 'rector'; - const ROLE_VICE_RECTOR = 'vice_rector'; - // dean - const ROLE_DEAN = 'dean'; - const ROLE_VICE_DEAN = 'vice_dean'; - // faculty - const ROLE_HEAD_FACULTY = 'head_faculty'; - const ROLE_VICE_HEAD_FACULTY = 'vice_head_faculty'; - - // department - const ROLE_HEAD_DEPARTMENT = 'head_department'; - const ROLE_VICE_HEAD_DEPARTMENT = 'vice_head_department'; - - // headmaster - const ROLE_HEADMASTER = 'headmaster'; - const ROLE_VICE_HEADMASTER = 'vice_headmaster'; - // hrd - const ROLE_HUMAN_RESOURCE_DEPARTMENT = 'human_resource_department'; - const ROLE_HUMAN_RESOURCE_MANAGEMENT = 'human_resource_management'; - - // lecturer - const ROLE_LECTURER = 'lecturer'; - const ROLE_TEACHER = 'teacher'; - const ROLE_COUNSELING_GUIDANCE = 'counseling_guidance'; - const ROLE_SUPERVISOR = 'supervisor'; - const ROLE_LIBRARIAN = 'librarian'; - // staff - const ROLE_OFFICE_STAFF = 'office_staff'; - // treasurer - const ROLE_TREASURER = 'treasurer'; - // office admin - const ROLE_OFFICE_ADMINISTRATION = 'office_admin'; - // other staff / worker - const ROLE_STAFF = 'staff'; - - protected array $availableRoles = [ - self::ROLE_SUPER_ADMIN, - self::ROLE_ADMIN, - self::ROLE_PRESIDENT, - self::ROLE_VICE_PRESIDENT, - self::ROLE_RECTOR, - self::ROLE_VICE_RECTOR, - self::ROLE_DEAN, - self::ROLE_VICE_DEAN, - self::ROLE_HEAD_FACULTY, - self::ROLE_VICE_HEAD_FACULTY, - self::ROLE_HEAD_DEPARTMENT, - self::ROLE_VICE_HEAD_DEPARTMENT, - self::ROLE_HEADMASTER, - self::ROLE_VICE_HEADMASTER, - self::ROLE_HUMAN_RESOURCE_DEPARTMENT, - self::ROLE_HUMAN_RESOURCE_MANAGEMENT, - self::ROLE_LECTURER, - self::ROLE_TEACHER, - self::ROLE_COUNSELING_GUIDANCE, - self::ROLE_SUPERVISOR, - self::ROLE_LIBRARIAN, - self::ROLE_OFFICE_STAFF, - self::ROLE_TREASURER, - self::ROLE_OFFICE_ADMINISTRATION, - self::ROLE_STAFF, - ]; + public const TABLE_NAME = 'admins'; + + #[Column( + name: 'identity_number', + type: Types::STRING, + length: 255, + nullable: true, + updatable: true, + options: [ + 'default' => null, + 'comment' => 'Unique identity number' + ] + )] + protected ?string $identity_number = null; + #[Column( + name: 'username', + type: Types::STRING, + length: 255, + nullable: false, + updatable: true, + options: [ + 'comment' => 'Unique username' + ] + )] + protected string $username; + + #[Column( + name: 'email', + type: Types::STRING, + length: 320, + nullable: false, + updatable: true, + options: [ + 'comment' => 'Unique email' + ] + )] + protected string $email; + + #[Column( + name: 'site_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'default' => null, + 'unsigned' => true, + 'comment' => 'Site id' + ] + )] + protected ?int $site_id = null; + + #[ + JoinColumn( + name: 'site_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'RESTRICT', + options: [ + 'relation_name' => 'relation_admins_site_id_sites_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'RESTRICT' + ] + ), + ManyToOne( + targetEntity: Site::class, + cascade: [ + "persist" + ], + fetch: 'EAGER' + ) + ] + protected ?Site $site = null; #[ JoinColumn( @@ -166,6 +178,27 @@ class Admin extends AbstractUser ] protected ?Role $roleObject = null; + public function getSiteId(): ?int + { + return $this->site_id; + } + + public function setSiteId(?int $site_id): void + { + $this->site_id = $site_id; + } + + public function getSite(): ?Site + { + return $this->site; + } + + public function setSite(?Site $site): void + { + $this->site = $site; + $this->setSiteId($site?->getId()); + } + public function getObjectRole(): Role { if (!$this->roleObject) { diff --git a/Entities/AdminLog.php b/Entities/AdminLog.php index 89f35e8..e8d9349 100644 --- a/Entities/AdminLog.php +++ b/Entities/AdminLog.php @@ -31,7 +31,7 @@ #[HasLifecycleCallbacks] class AdminLog extends AbstractUserBasedLog { - const TABLE_NAME = 'admin_logs'; + public const TABLE_NAME = 'admin_logs'; #[ JoinColumn( diff --git a/Entities/AdminMeta.php b/Entities/AdminMeta.php index ae0cb79..43b9401 100644 --- a/Entities/AdminMeta.php +++ b/Entities/AdminMeta.php @@ -42,7 +42,7 @@ */ class AdminMeta extends AbstractBasedMeta { - const TABLE_NAME = 'admin_meta'; + public const TABLE_NAME = 'admin_meta'; #[Id] #[Column( diff --git a/Entities/AdminOnlineActivity.php b/Entities/AdminOnlineActivity.php index 3c9a6fb..42f4b34 100644 --- a/Entities/AdminOnlineActivity.php +++ b/Entities/AdminOnlineActivity.php @@ -20,6 +20,10 @@ 'comment' => 'Administrator user online activity', ] )] +#[Index( + columns: ['updated_at'], + name: 'index_updated_at' +)] #[Index( columns: ['user_id', 'name', 'created_at', 'updated_at'], name: 'index_user_id_name_created_at_updated_at' @@ -31,7 +35,7 @@ #[HasLifecycleCallbacks] class AdminOnlineActivity extends AbstractBasedOnlineActivity { - const TABLE_NAME = 'admin_online_activities'; + public const TABLE_NAME = 'admin_online_activities'; #[ JoinColumn( diff --git a/Entities/Attachment.php b/Entities/Attachment.php deleted file mode 100644 index 528b98c..0000000 --- a/Entities/Attachment.php +++ /dev/null @@ -1,80 +0,0 @@ - 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'comment' => 'Attachments created by admin user', - ] -)] -#[UniqueConstraint( - name: 'unique_path_storage_type', - columns: ['path', 'storage_type'] -)] -#[Index( - columns: ['storage_type', 'mime_type'], - name: 'index_storage_type_mime_type' -)] -#[Index( - columns: ['user_id'], - name: 'relation_attachments_user_id_admins_id' -)] -#[Index( - columns: ['name', 'file_name', 'status', 'mime_type', 'storage_type'], - name: 'index_name_file_name_status_mime_type_storage_type' -)] -#[HasLifecycleCallbacks] -class Attachment extends AbstractAttachment -{ - const TABLE_NAME = 'attachments'; - - #[ - JoinColumn( - name: 'user_id', - referencedColumnName: 'id', - nullable: true, - onDelete: 'SET NULL', - options: [ - 'relation_name' => 'relation_attachments_user_id_admins_id', - 'onUpdate' => 'CASCADE', - 'onDelete' => 'SET NULL' - ], - ), - ManyToOne( - targetEntity: Admin::class, - cascade: [ - 'persist' - ], - fetch: 'LAZY', - ) - ] - protected ?Admin $user = null; - - public function setUser(?Admin $user): void - { - $this->user = $user; - $this->setUserId($user?->getId()); - } - - public function getUser(): ?Admin - { - return $this->user; - } -} diff --git a/Entities/Capability.php b/Entities/Capability.php index 9512b50..94a8fa7 100644 --- a/Entities/Capability.php +++ b/Entities/Capability.php @@ -1,5 +1,4 @@ 'utf8mb4', // remove this or change to utf8 if not use mysql 'collation' => 'utf8mb4_unicode_ci', // remove this if not use mysql - 'comment' => 'Capabilities' + 'comment' => 'Capabilities', + 'priority' => 0, + 'primaryKey' => [ + 'identity', + 'site_id' + ], ] )] #[Index( - columns: ['name'], - name: 'index_name' + columns: ['identity', 'site_id'], + name: 'index_identity_site_id' +)] +#[Index( + columns: ['site_id'], + name: 'relation_capabilities_site_id_sites_id' )] #[Index( columns: ['type'], @@ -51,12 +64,11 @@ #[HasLifecycleCallbacks] class Capability extends AbstractEntity implements CapabilityEntityInterface { - const TABLE_NAME = 'capabilities'; - const TYPE_USER = 'user'; - const TYPE_ADMIN = 'admin'; + public const TABLE_NAME = 'capabilities'; - const TYPE_GLOBAL = null; - const TYPE_GLOBAL_ALTERNATE = 'global'; + public const TYPE_GLOBAL = 'global'; + + public const TYPE_GLOBAL_ALTERNATE = null; #[Id] #[Column( @@ -103,6 +115,44 @@ class Capability extends AbstractEntity implements CapabilityEntityInterface )] protected ?string $type = null; + #[Column( + name: 'site_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'unsigned' => true, + 'default' => null, + 'comment' => 'Site id' + ] + )] + protected ?int $site_id = null; + + #[ + JoinColumn( + name: 'site_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'CASCADE', + options: [ + 'relation_name' => 'relation_capabilities_site_id_sites_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'CASCADE' + ] + ), + ManyToOne( + targetEntity: Site::class, + cascade: [ + "persist" + ], + fetch: 'EAGER' + ) + ] + protected ?Site $site = null; + + /** + * @var ?Collection $roleCapability + */ #[OneToMany( mappedBy: 'capability', targetEntity: RoleCapability::class, @@ -114,9 +164,6 @@ class Capability extends AbstractEntity implements CapabilityEntityInterface ], fetch: 'LAZY' )] - /** - * @var ?Collection $roleCapability - */ protected ?Collection $roleCapability = null; public function getIdentity(): string @@ -156,27 +203,32 @@ public function getType(): ?string public function setType(?string $type): void { - $type = is_string($type) ? strtolower(trim($type)) : $type; - $this->type = $type; + $this->type = $this->normalizeType($type); } - public function isGlobal() : bool + public function normalizeType(?string $type): string { - $type = $this->getType(); - $type = is_string($type) ? strtolower(trim($type)) : $type; - return $type === '' || $type === null || $type === self::TYPE_GLOBAL; + return $type && $type !== self::TYPE_GLOBAL ? match (trim($type)) { + '' => self::TYPE_GLOBAL_ALTERNATE, + default => strtolower(trim($type)) + } : self::TYPE_GLOBAL_ALTERNATE; } - public function isUser() : bool + public function getNormalizeType() : string { - $type = $this->getType(); - return (is_string($type) ? trim($type) : $type) === self::TYPE_USER; + return $this->normalizeType($this->getType()); } - public function isAdmin() : bool + public function isType(?string $type): bool + { + return $this->normalizeType($type) === $this->getNormalizeType(); + } + + public function isGlobal() : bool { $type = $this->getType(); - return (is_string($type) ? trim($type) : $type) === self::TYPE_ADMIN; + $type = is_string($type) ? strtolower(trim($type)) : $type; + return $type === '' || $type === null || $type === self::TYPE_GLOBAL; } /** @@ -187,24 +239,6 @@ public function getRoleCapability(): ?Collection return $this->roleCapability; } - /** @noinspection PhpUnusedParameterInspection */ - #[ - PostLoad, - PrePersist - ] - public function postLoadChangeIdentity( - PrePersistEventArgs|PostLoadEventArgs $event - ) : void { - $this->identity = strtolower(trim($this->identity)); - $this->identity = preg_replace('~[\s_]+~', '_', $this->identity); - $this->identity = trim($this->identity, '_'); - if ($this->identity === '') { - throw new EmptyArgumentException( - 'Identity could not being empty or contain whitespace only' - ); - } - } - public function has(RoleInterface|string $role) : bool { $role = is_object($role) ? $role->getRole() : $role; @@ -226,4 +260,49 @@ static function (&$key, RoleCapability $r) { } ); } + + public function getSiteId(): ?int + { + return $this->site_id; + } + + public function setSiteId(?int $site_id): void + { + $this->site_id = $site_id; + } + + public function getSite(): ?Site + { + return $this->site; + } + + public function setSite(?Site $site): void + { + $this->site = $site; + $this->setSiteId($site?->getId()); + } + + #[ + PostLoad, + PrePersist + ] + public function postLoadPersistEventChange( + PrePersistEventArgs|PostLoadEventArgs $event + ) : void { + $isPostLoad = $event instanceof PostLoadEventArgs; + if ($isPostLoad) { + $normalizeType = $this->getNormalizeType(); + if ($normalizeType !== $this->type) { + $this->type = $normalizeType; + } + } + $this->identity = strtolower(trim($this->identity)); + $this->identity = preg_replace('~[\s_]+~', '_', $this->identity); + $this->identity = trim($this->identity, '_'); + if (! $isPostLoad && $this->identity === '') { + throw new EmptyArgumentException( + 'Identity could not being empty or contain whitespace only' + ); + } + } } diff --git a/Entities/Options.php b/Entities/Options.php new file mode 100644 index 0000000..a2a0731 --- /dev/null +++ b/Entities/Options.php @@ -0,0 +1,170 @@ + 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => 'Site settings', + 'primaryKey' => ['name', 'site_id'] + ] +)] +#[Index( + columns: ['site_id'], + name: 'relation_options_site_id_sites_id' +)] +#[HasLifecycleCallbacks] +class Options extends AbstractEntity +{ + public const TABLE_NAME = 'options'; + + #[Id] + #[Column( + name: 'name', + type: Types::STRING, + length: 255, + nullable: false, + updatable: true, + options: [ + 'comment' => 'Option name primary key' + ] + )] + protected string $name; + + #[Column( + name: 'site_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'unsigned' => true, + 'default' => null, + 'comment' => 'Site id' + ] + )] + protected ?int $site_id = null; + + #[ + JoinColumn( + name: 'site_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'CASCADE', + options: [ + 'relation_name' => 'relation_options_site_id_sites_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'CASCADE' + ] + ), + ManyToOne( + targetEntity: Site::class, + cascade: [ + "persist" + ], + fetch: 'EAGER' + ) + ] + protected ?Site $site = null; + + #[Column( + name: 'value', + type: TypeList::DATA, + length: 4294967295, + nullable: true, + options: [ + 'default' => null, + 'comment' => 'Option value data' + ] + )] + protected mixed $value = null; + + #[Column( + name: 'autoload', + type: Types::BOOLEAN, + nullable: false, + options: [ + 'default' => false, + 'comment' => 'Autoload reference' + ] + )] + protected bool $autoload = false; + + public function __construct() + { + $this->autoload = false; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function isAutoload(): bool + { + return $this->autoload; + } + + public function setAutoload(bool $autoload): void + { + $this->autoload = $autoload; + } + + public function getValue(): mixed + { + return $this->value; + } + + public function setValue(mixed $value): void + { + $this->value = $value; + } + + public function getSiteId(): ?int + { + return $this->site_id; + } + public function setSiteId(?int $site_id): void + { + $this->site_id = $site_id; + } + + public function getSite(): ?Site + { + return $this->site; + } + + public function setSite(?Site $site): void + { + $this->site = $site; + $this->setSiteId($site?->getId()); + } +} diff --git a/Entities/Role.php b/Entities/Role.php index 7370831..b714d76 100644 --- a/Entities/Role.php +++ b/Entities/Role.php @@ -15,11 +15,14 @@ use Doctrine\ORM\Mapping\HasLifecycleCallbacks; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\Index; +use Doctrine\ORM\Mapping\JoinColumn; +use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\PostLoad; use Doctrine\ORM\Mapping\PrePersist; use Doctrine\ORM\Mapping\PreUpdate; use Doctrine\ORM\Mapping\Table; +use Doctrine\ORM\Mapping\UniqueConstraint; #[Entity] #[Table( @@ -27,12 +30,21 @@ options: [ 'charset' => 'utf8mb4', // remove this or change to utf8 if not use mysql 'collation' => 'utf8mb4_unicode_ci', // remove this if not use mysql - 'comment' => 'Table roles' + 'comment' => 'Table roles', + 'priority' => 1, + 'primaryKey' => [ + 'identity', + 'site_id' + ], ] )] #[Index( - columns: ['name'], - name: 'index_name' + columns: ['identity', 'site_id'], + name: 'index_identity_site_id' +)] +#[Index( + columns: ['site_id'], + name: 'relation_roles_site_id_sites_id' )] #[HasLifecycleCallbacks] /** @@ -42,7 +54,7 @@ */ class Role extends AbstractEntity implements RoleInterface { - const TABLE_NAME = 'roles'; + public const TABLE_NAME = 'roles'; private ?string $originIdentity = null; @@ -79,6 +91,41 @@ class Role extends AbstractEntity implements RoleInterface )] protected ?string $description = null; + #[Column( + name: 'site_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'unsigned' => true, + 'default' => null, + 'comment' => 'Site id' + ] + )] + protected ?int $site_id = null; + + #[ + JoinColumn( + name: 'site_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'CASCADE', + options: [ + 'relation_name' => 'relation_roles_site_id_sites_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'CASCADE' + ] + ), + ManyToOne( + targetEntity: Site::class, + cascade: [ + "persist" + ], + fetch: 'EAGER' + ) + ] + protected ?Site $site = null; + #[OneToMany( mappedBy: 'role', targetEntity: RoleCapability::class, @@ -90,7 +137,7 @@ class Role extends AbstractEntity implements RoleInterface ], fetch: 'LAZY' )] - protected ?Collection $roleCapability = null; + protected ?Collection $roleCapabilities = null; public function getIdentity(): string { @@ -133,9 +180,33 @@ public function getRole(): string return $this->getIdentity(); } - public function getRoleCapability(): ?Collection + public function getSiteId(): ?int + { + return $this->site_id; + } + + public function setSiteId(?int $site_id): void + { + $this->site_id = $site_id; + } + + public function getSite(): ?Site + { + return $this->site; + } + + public function setSite(?Site $site): void + { + $this->site = $site; + $this->setSiteId($site?->getId()); + } + + /** + * @return ?Collection + */ + public function getRoleCapabilities(): ?Collection { - return $this->roleCapability; + return $this->roleCapabilities; } #[ diff --git a/Entities/RoleCapability.php b/Entities/RoleCapability.php index 2238668..3b19988 100644 --- a/Entities/RoleCapability.php +++ b/Entities/RoleCapability.php @@ -11,6 +11,7 @@ use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\Index; use Doctrine\ORM\Mapping\JoinColumn; +use Doctrine\ORM\Mapping\JoinTable; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\Table; @@ -21,24 +22,25 @@ 'charset' => 'utf8mb4', // remove this or change to utf8 if not use mysql 'collation' => 'utf8mb4_unicode_ci', // remove this if not use mysql 'comment' => 'Role relation capability & meta', + 'priority' => 2, 'primaryKey' => [ - 'class_id', - 'name' + 'role_identity', + 'capability_identity' ] ] )] #[Index( - columns: ['role_identity'], - name: 'relation_capabilities_role_identity_roles_identity' + columns: ['role_identity', 'site_id'], + name: 'relation_role_capabilities_identity_roles_identity_sites_id' )] #[Index( - columns: ['capability_identity'], - name: 'relation_capabilities_capability_identity_capabilities_identity' + columns: ['capability_identity', 'site_id'], + name: 'relation_roles_cap_cap_id_capabilities_identity_site_id' )] #[HasLifecycleCallbacks] class RoleCapability extends AbstractEntity { - const TABLE_NAME = 'role_capabilities'; + public const TABLE_NAME = 'role_capabilities'; #[Id] #[Column( @@ -64,6 +66,7 @@ class RoleCapability extends AbstractEntity )] protected string $capability_identity; + #[JoinTable(name: Role::TABLE_NAME)] #[ JoinColumn( name: 'role_identity', @@ -71,7 +74,18 @@ class RoleCapability extends AbstractEntity nullable: false, onDelete: 'RESTRICT', options: [ - 'relation_name' => 'relation_capabilities_role_identity_roles_identity', + 'relation_name' => 'relation_role_capabilities_identity_roles_identity_sites_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'RESTRICT' + ] + ), + JoinColumn( + name: 'site_id', + referencedColumnName: 'site_id', + nullable: false, + onDelete: 'RESTRICT', + options: [ + 'relation_name' => 'relation_role_capabilities_identity_roles_identity_sites_id', 'onUpdate' => 'CASCADE', 'onDelete' => 'RESTRICT' ] @@ -89,6 +103,7 @@ class RoleCapability extends AbstractEntity ] protected Role $role; + #[JoinTable(name: Capability::TABLE_NAME)] #[ JoinColumn( name: 'capability_identity', @@ -96,7 +111,18 @@ class RoleCapability extends AbstractEntity nullable: false, onDelete: 'RESTRICT', options: [ - 'relation_name' => 'relation_capabilities_capability_identity_capabilities_identity', + 'relation_name' => 'relation_roles_cap_cap_id_capabilities_identity_site_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'RESTRICT' + ] + ), + JoinColumn( + name: 'site_id', + referencedColumnName: 'site_id', + nullable: false, + onDelete: 'RESTRICT', + options: [ + 'relation_name' => 'relation_roles_cap_cap_id_capabilities_identity_site_id', 'onUpdate' => 'CASCADE', 'onDelete' => 'RESTRICT' ] diff --git a/Entities/Site.php b/Entities/Site.php new file mode 100644 index 0000000..466c671 --- /dev/null +++ b/Entities/Site.php @@ -0,0 +1,373 @@ + 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => 'Sites', + 'priority' => PHP_INT_MIN + 1000, + ] +)] +#[UniqueConstraint( + name: 'unique_uuid', + columns: ['uuid'], +)] +#[UniqueConstraint( + name: 'unique_domain', + columns: ['domain'], +)] +#[Index( + columns: ['user_id'], + name: 'relation_sites_user_id_admins_id' +)] +#[Index( + columns: ['domain', 'domain_alias'], + name: 'index_domain_domain_alias' +)] +#[Index( + columns: ['name', 'domain', 'domain_alias', 'status'], + name: 'index_name_domain_domain_alias_status' +)] +#[HasLifecycleCallbacks] +class Site extends AbstractEntity implements IdentityBasedEntityInterface, AvailabilityStatusEntityInterface +{ + public const TABLE_NAME = 'sites'; + + use AvailabilityStatusTrait; + + #[Id] + #[GeneratedValue('AUTO')] + #[Column( + name: 'id', + type: Types::BIGINT, + length: 20, + updatable: false, + options: [ + 'unsigned' => true, + 'comment' => 'Attachment Id' + ] + )] + protected int $id; + + #[Column( + name: 'uuid', + type: Types::STRING, + length: 36, + updatable: false, + options: [ + 'comment' => 'Site UUID' + ] + )] + protected string $uuid; + + #[Column( + name: 'name', + type: Types::STRING, + length: 255, + nullable: false, + options: [ + 'comment' => 'Site name' + ] + )] + protected string $name; + + #[Column( + name: 'domain', + type: Types::STRING, + length: 255, + unique: true, + nullable: false, + options: [ + 'comment' => 'Site domain' + ] + )] + protected string $domain; + + #[Column( + name: 'domain_alias', + type: Types::STRING, + length: 64, + nullable: true, + options: [ + 'default' => null, + 'comment' => 'Site domain alias (formerly use subdomain)' + ] + )] + protected ?string $domain_alias = null; + + #[Column( + name: 'description', + type: Types::TEXT, + length: 4294967295, + nullable: true, + options: [ + 'default' => null, + 'comment' => 'Site description' + ] + )] + protected ?string $description = null; + + #[Column( + name: 'user_id', + type: Types::BIGINT, + length: 20, + nullable: true, + updatable: true, + options: [ + 'default' => null, + 'unsigned' => true, + 'comment' => 'Site Owner' + ] + )] + protected ?int $user_id = null; + + #[Column( + name: 'status', + type: Types::STRING, + length: 64, + nullable: false, + options: [ + 'comment' => 'Announcement status' + ] + )] + protected string $status; + + #[Column( + name: 'created_at', + type: Types::DATETIME_IMMUTABLE, + updatable: false, + options: [ + 'default' => 'CURRENT_TIMESTAMP', + 'comment' => 'Announcement created time' + ] + )] + protected DateTimeInterface $created_at; + + #[Column( + name: 'updated_at', + type: Types::DATETIME_IMMUTABLE, + unique: false, + updatable: false, + options: [ + 'attribute' => 'ON UPDATE CURRENT_TIMESTAMP', + 'default' => '0000-00-00 00:00:00', + 'comment' => 'Announcement update time' + ], + // columnDefinition: "DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP" + )] + protected DateTimeInterface $updated_at; + + #[Column( + name: 'deleted_at', + type: Types::DATETIME_IMMUTABLE, + nullable: true, + options: [ + 'default' => null, + 'comment' => 'Announcement delete time' + ] + )] + protected ?DateTimeInterface $deleted_at = null; + + #[ + JoinColumn( + name: 'user_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'RESTRICT', + options: [ + 'relation_name' => 'relation_sites_user_id_admins_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'RESTRICT' + ], + ), + ManyToOne( + targetEntity: Admin::class, + cascade: [ + 'persist' + ], + fetch: 'LAZY' + ) + ] + protected ?Admin $user = null; + + /** + * Allow associations mapping + * @see jsonSerialize() + * + * @var bool + */ + protected bool $entityAllowAssociations = true; + + private bool $postLoad = false; + + public function __construct() + { + $this->uuid = UUID::v4(); + $this->domain_alias = 'www'; + $this->created_at = new DateTimeImmutable(); + $this->updated_at = new DateTimeImmutable('0000-00-00 00:00:00'); + $this->deleted_at = null; + } + + public function getId(): ?int + { + return $this->id??null; + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function setUuid(string $uuid): void + { + $this->uuid = $uuid; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getDomain(): string + { + return $this->domain; + } + + public function setDomain(string $domain): void + { + $this->domain = $domain; + } + + public function getDomainAlias(): ?string + { + return $this->domain_alias; + } + + public function setDomainAlias(?string $domain_alias): void + { + $this->domain_alias = $domain_alias; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function getUserId(): ?int + { + return $this->user_id; + } + + public function setUserId(?int $user_id): void + { + $this->user_id = $user_id; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): void + { + $this->status = $status; + } + + public function getCreatedAt(): DateTimeInterface + { + return $this->created_at; + } + + public function getUpdatedAt(): DateTimeInterface + { + return $this->updated_at; + } + + public function getDeletedAt(): ?DateTimeInterface + { + return $this->deleted_at; + } + + public function setDeletedAt(?DateTimeInterface $deleted_at): void + { + $this->deleted_at = $deleted_at; + } + + public function getUser(): ?Admin + { + return $this->user; + } + + public function setUser(?Admin $user): void + { + $this->user = $user; + $this->setUserId($user?->getId()); + } + + final public function isPostLoad(): bool + { + return $this->postLoad; + } + + #[PostLoad] + final public function finalPostLoaded(PostLoadEventArgs $postLoadEventArgs): void + { + print_r(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)); + exit; + $this->postLoad = true; + } +} diff --git a/Entities/User.php b/Entities/User.php index 0f765f7..bc73393 100644 --- a/Entities/User.php +++ b/Entities/User.php @@ -1,12 +1,10 @@ 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'comment' => 'User lists', + 'priority' => 5, ] )] #[UniqueConstraint( - name: 'unique_username', - columns: ['username'] + name: 'unique_username_site_id', + columns: ['username', 'site_id'] )] #[UniqueConstraint( - name: 'unique_email', - columns: ['email'] + name: 'unique_email_site_id', + columns: ['email', 'site_id'] )] #[UniqueConstraint( - name: 'unique_identity_number', - columns: ['identity_number'] + name: 'unique_identity_number_site_id', + columns: ['identity_number', 'site_id'] )] #[Index( - columns: ['school_year', 'status'], - name: 'index_school_year_status' + columns: ['username', 'status', 'role', 'first_name', 'last_name', 'site_id'], + name: 'index_username_status_role_first_name_last_name_site_id' )] -#[Index( - columns: ['username', 'status', 'role', 'first_name', 'last_name', 'school_year'], - name: 'index_username_status_role_first_name_last_name_school_year' -)] -/* -#[Index( - columns: ['class_id', 'role'], - name: 'index_class_id_role' -)] -#[Index( - columns: ['class_id'], - name: 'relation_users_class_id_classes_id' -)] -*/ - #[Index( columns: ['related_user_id'], name: 'relation_users_related_user_id_users_id' @@ -81,61 +65,85 @@ columns: ['role'], name: 'relation_users_role_roles_identity' )] +#[Index( + columns: ['site_id'], + name: 'relation_users_site_id_sites_id' +)] #[HasLifecycleCallbacks] class User extends AbstractUser { - const TABLE_NAME = 'users'; - const ROLE_STUDENT = 'student'; - const ROLE_ALUMNI = 'alumni'; - const ROLE_GUARDIAN = 'guardian'; - const ROLE_GUEST = 'guest'; + public const TABLE_NAME = 'users'; - protected array $availableRoles = [ - self::ROLE_STUDENT, - self::ROLE_ALUMNI, - self::ROLE_GUARDIAN, - self::ROLE_GUEST, - ]; + #[Column( + name: 'identity_number', + type: Types::STRING, + length: 255, + nullable: true, + updatable: true, + options: [ + 'default' => null, + 'comment' => 'Unique identity number' + ] + )] + protected ?string $identity_number = null; + #[Column( + name: 'username', + type: Types::STRING, + length: 255, + nullable: false, + updatable: true, + options: [ + 'comment' => 'Unique username' + ] + )] + protected string $username; - /* #[Column( - name: 'class_id', + name: 'email', + type: Types::STRING, + length: 320, + nullable: false, + updatable: true, + options: [ + 'comment' => 'Unique email' + ] + )] + protected string $email; + + #[Column( + name: 'site_id', type: Types::BIGINT, length: 20, nullable: true, options: [ - 'unsigned' => true, 'default' => null, - 'comment' => 'Class id' + 'unsigned' => true, + 'comment' => 'Site id' ] )] - protected ?int $class_id = null; + protected ?int $site_id = null; #[ JoinColumn( - name: 'class_id', + name: 'site_id', referencedColumnName: 'id', nullable: true, - onDelete: 'CASCADE', + onDelete: 'RESTRICT', options: [ - 'relation_name' => 'relation_users_class_id_classes_id', + 'relation_name' => 'relation_users_site_id_sites_id', 'onUpdate' => 'CASCADE', - 'onDelete' => 'CASCADE' + 'onDelete' => 'RESTRICT' ] ), ManyToOne( - targetEntity: Classes::class, + targetEntity: Site::class, cascade: [ - "persist", - "remove", - "merge", - "detach" + "persist" ], fetch: 'EAGER' ) ] - protected ?Classes $class; - */ + protected ?Site $site = null; #[Column( name: 'related_user_id', @@ -143,23 +151,13 @@ class User extends AbstractUser length: 20, nullable: true, options: [ - 'unsigned' => true, 'default' => null, + 'unsigned' => true, 'comment' => 'Relational user id' ] )] protected ?int $related_user_id = null; - #[Column( - name: 'school_year', - type: TypeList::YEAR, - nullable: false, - options: [ - 'comment' => 'User school year' - ] - )] - protected DateTimeInterface $school_year; - #[ JoinColumn( name: 'related_user_id', @@ -226,53 +224,26 @@ class User extends AbstractUser ] protected ?Role $roleObject = null; - public function getSchoolYear(): DateTimeInterface - { - return $this->school_year; - } - - public function setSchoolYear(DateTimeInterface|int|null $school_year): void - { - /** @noinspection DuplicatedCode */ - if (is_int($school_year)) { - $school_year = (string) $school_year; - if ($school_year < 1000) { - do { - $school_year = "0$school_year"; - } while (strlen($school_year) < 4); - } - $school_year = substr($school_year, 0, 4); - $school_year = DateTimeImmutable::createFromFormat( - '!Y-m-d', - "$school_year-01-01" - )?:new DateTimeImmutable("$school_year-01-01 00:00:00"); - } - - $this->school_year = $school_year; - } - - public function getClassId(): ?int + public function getSiteId(): ?int { - return $this->class_id; + return $this->site_id; } - /* - public function setClassId(?int $class_id): void + public function setSiteId(?int $site_id): void { - $this->class_id = $class_id; + $this->site_id = $site_id; } - public function getClass(): ?Classes + public function getSite(): ?Site { - return $this->class; + return $this->site; } - public function setClass(?Classes $class): void + public function setSite(?Site $site): void { - $this->class = $class; - $this->setClassId($class?->getId()); + $this->site = $site; + $this->setSiteId($site?->getId()); } - */ public function getObjectRole(): Role { diff --git a/Entities/UserAttachment.php b/Entities/UserAttachment.php deleted file mode 100644 index 41680cb..0000000 --- a/Entities/UserAttachment.php +++ /dev/null @@ -1,80 +0,0 @@ - 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'comment' => 'Attachments created by common user', - ] -)] -#[UniqueConstraint( - name: 'unique_path_storage_type', - columns: ['path', 'storage_type'] -)] -#[Index( - columns: ['storage_type', 'mime_type'], - name: 'index_storage_type_mime_type' -)] -#[Index( - columns: ['user_id'], - name: 'relation_user_attachments_user_id_users_id' -)] -#[Index( - columns: ['name', 'file_name', 'status', 'mime_type', 'storage_type'], - name: 'index_name_file_name_status_mime_type_storage_type' -)] -#[HasLifecycleCallbacks] -class UserAttachment extends AbstractAttachment -{ - const TABLE_NAME = 'user_attachments'; - - #[ - JoinColumn( - name: 'user_id', - referencedColumnName: 'id', - nullable: true, - onDelete: 'SET NULL', - options: [ - 'relation_name' => 'relation_user_attachments_user_id_users_id', - 'onUpdate' => 'CASCADE', - 'onDelete' => 'SET NULL' - ], - ), - ManyToOne( - targetEntity: User::class, - cascade: [ - 'persist' - ], - fetch: 'LAZY', - ) - ] - protected ?User $user = null; - - public function setUser(?User $user): void - { - $this->user = $user; - $this->setUserId($user?->getId()); - } - - public function getUser(): ?User - { - return $this->user; - } -} diff --git a/Entities/UserLog.php b/Entities/UserLog.php index 11fd771..6ac6cc0 100644 --- a/Entities/UserLog.php +++ b/Entities/UserLog.php @@ -31,7 +31,7 @@ #[HasLifecycleCallbacks] class UserLog extends AbstractUserBasedLog { - const TABLE_NAME = 'user_logs'; + public const TABLE_NAME = 'user_logs'; #[ JoinColumn( diff --git a/Entities/UserMeta.php b/Entities/UserMeta.php index 18f39bb..9223717 100644 --- a/Entities/UserMeta.php +++ b/Entities/UserMeta.php @@ -42,7 +42,7 @@ */ class UserMeta extends AbstractBasedMeta { - const TABLE_NAME = 'user_meta'; + public const TABLE_NAME = 'user_meta'; #[Id] #[Column( diff --git a/Entities/UserOnlineActivity.php b/Entities/UserOnlineActivity.php index ccacff2..4d93526 100644 --- a/Entities/UserOnlineActivity.php +++ b/Entities/UserOnlineActivity.php @@ -19,6 +19,10 @@ 'comment' => 'Common user online activity', ] )] +#[Index( + columns: ['updated_at'], + name: 'index_updated_at' +)] #[Index( columns: ['user_id', 'name', 'created_at', 'updated_at'], name: 'index_user_id_name_created_at_updated_at' @@ -29,7 +33,7 @@ )] class UserOnlineActivity extends AbstractBasedOnlineActivity { - const TABLE_NAME = 'user_online_activities'; + public const TABLE_NAME = 'user_online_activities'; #[ JoinColumn( diff --git a/Entities/UserTerm.php b/Entities/UserTerm.php index 666f9c2..48b1fcc 100644 --- a/Entities/UserTerm.php +++ b/Entities/UserTerm.php @@ -37,13 +37,17 @@ ] )] #[UniqueConstraint( - name: 'unique_name', - columns: ['name'] + name: 'unique_name_site_id', + columns: ['name', 'site_id'] )] #[Index( columns: ['user_id'], name: 'relation_user_terms_user_id_admins_id' )] +#[Index( + columns: ['site_id'], + name: 'relation_user_terms_site_id_sites_id' +)] #[Index( columns: ['name', 'title', 'status'], name: 'index_name_title_status' @@ -60,10 +64,11 @@ * @property-read DateTimeInterface $created_at * @property-read DateTimeInterface $updated_at * @property-read ?DateTimeInterface $deleted_at + * @property-read ?Site $site */ class UserTerm extends AbstractEntity implements AvailabilityStatusEntityInterface { - const TABLE_NAME = 'user_terms'; + public const TABLE_NAME = 'user_terms'; use AvailabilityStatusTrait, PasswordTrait; @@ -81,7 +86,6 @@ class UserTerm extends AbstractEntity implements AvailabilityStatusEntityInterfa ] )] protected int $id; - #[Column( name: 'user_id', type: Types::BIGINT, @@ -95,6 +99,19 @@ class UserTerm extends AbstractEntity implements AvailabilityStatusEntityInterfa )] protected ?int $user_id = null; + #[Column( + name: 'site_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'unsigned' => true, + 'default' => null, + 'comment' => 'Admin id' + ] + )] + protected ?int $site_id = null; + #[Column( name: 'name', type: Types::STRING, @@ -165,7 +182,7 @@ class UserTerm extends AbstractEntity implements AvailabilityStatusEntityInterfa 'comment' => 'Published at' ] )] - protected ?DateTimeInterface $published_at; + protected ?DateTimeInterface $published_at = null; #[Column( name: 'created_at', @@ -203,6 +220,28 @@ class UserTerm extends AbstractEntity implements AvailabilityStatusEntityInterfa )] protected ?DateTimeInterface $deleted_at = null; + #[ + JoinColumn( + name: 'site_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'CASCADE', + options: [ + 'relation_name' => 'relation_user_terms_site_id_sites_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'CASCADE' + ] + ), + ManyToOne( + targetEntity: Site::class, + cascade: [ + "persist" + ], + fetch: 'EAGER' + ) + ] + protected ?Site $site = null; + #[ JoinColumn( name: 'user_id', @@ -227,16 +266,8 @@ class UserTerm extends AbstractEntity implements AvailabilityStatusEntityInterfa public function __construct() { - $this->user_id = null; - $this->title = null; - $this->content = ''; - $this->user = null; - $this->status = self::DRAFT; - $this->password = null; $this->created_at = new DateTimeImmutable(); $this->updated_at = new DateTimeImmutable('0000-00-00 00:00:00'); - $this->published_at = null; - $this->deleted_at = null; } public function getId() : int @@ -345,6 +376,27 @@ public function setUser(?Admin $user): void $this->setUserId($user?->getId()); } + public function getSiteId(): ?int + { + return $this->site_id; + } + + public function setSiteId(?int $site_id): void + { + $this->site_id = $site_id; + } + + public function getSite(): ?Site + { + return $this->site; + } + + public function setSite(?Site $site): void + { + $this->site = $site; + $this->setSiteId($site?->getId()); + } + #[ PreUpdate, PostLoad, diff --git a/Entities/UserTermGroup.php b/Entities/UserTermGroup.php index 01a9e7f..b321343 100644 --- a/Entities/UserTermGroup.php +++ b/Entities/UserTermGroup.php @@ -49,7 +49,7 @@ */ class UserTermGroup extends AbstractEntity implements AvailabilityStatusEntityInterface { - const TABLE_NAME = 'user_term_groups'; + public const TABLE_NAME = 'user_term_groups'; use AvailabilityStatusTrait; @@ -101,7 +101,6 @@ class UserTermGroup extends AbstractEntity implements AvailabilityStatusEntityIn ] )] protected string $status; - #[ JoinColumn( name: 'user_id', diff --git a/Entities/UserTermGroupMeta.php b/Entities/UserTermGroupMeta.php index 3d8b1f9..b1d41d6 100644 --- a/Entities/UserTermGroupMeta.php +++ b/Entities/UserTermGroupMeta.php @@ -22,7 +22,7 @@ 'collation' => 'utf8mb4_unicode_ci', 'comment' => 'User term group metadata', 'primaryKey' => [ - 'post_id', + 'term_group_id', 'name' ] ] @@ -42,7 +42,7 @@ */ class UserTermGroupMeta extends AbstractBasedMeta { - const TABLE_NAME = 'user_term_group_meta'; + public const TABLE_NAME = 'user_term_group_meta'; #[Id] #[Column( diff --git a/Entities/UserTermMeta.php b/Entities/UserTermMeta.php index 77f96d8..a576311 100644 --- a/Entities/UserTermMeta.php +++ b/Entities/UserTermMeta.php @@ -22,8 +22,8 @@ 'collation' => 'utf8mb4_unicode_ci', 'comment' => 'User term metadata', 'primaryKey' => [ - 'post_id', - 'name' + 'term_id', + 'term_group_id' ] ] )] @@ -42,7 +42,7 @@ */ class UserTermMeta extends AbstractBasedMeta { - const TABLE_NAME = 'user_term_meta'; + public const TABLE_NAME = 'user_term_meta'; #[Id] #[Column( diff --git a/Languages/users-module.pot b/Languages/module.pot similarity index 81% rename from Languages/users-module.pot rename to Languages/module.pot index 09bb5ac..97cdfb4 100644 --- a/Languages/users-module.pot +++ b/Languages/module.pot @@ -20,17 +20,9 @@ msgstr "" "translatePluralContext:1,2,4c;transNX:1,2,4c;_nx:1,2,4c\n" "X-Poedit-SearchPath-0: Entities\n" "X-Poedit-SearchPath-1: Factory\n" -"X-Poedit-SearchPath-2: Users.php\n" +"X-Poedit-SearchPath-2: Controllers\n" +"X-Poedit-SearchPath-3: Routes\n" +"X-Poedit-SearchPath-4: Users.php\n" "X-Poedit-SearchPathExcluded-0: *.css\n" "X-Poedit-SearchPathExcluded-1: *.html\n" "X-Poedit-SearchPathExcluded-2: *.json\n" - -#: Users.php:96 -msgctxt "module" -msgid "Users & Auth" -msgstr "" - -#: Users.php:105 -msgctxt "module" -msgid "Core module that support users & authentication" -msgstr "" diff --git a/Route/Attributes/AbstractAPIAttributes.php b/Route/Attributes/AbstractAPIAttributes.php new file mode 100644 index 0000000..5ff8039 --- /dev/null +++ b/Route/Attributes/AbstractAPIAttributes.php @@ -0,0 +1,24 @@ +dispatch( + 'apiRoute.subPrefix', + $subPrefixOriginal, + static::class + ); + return trim(is_string($subPrefix) ? $subPrefix : $subPrefixOriginal, '/'); + } +} diff --git a/Route/Attributes/Dashboard.php b/Route/Attributes/Dashboard.php new file mode 100644 index 0000000..a49fd3b --- /dev/null +++ b/Route/Attributes/Dashboard.php @@ -0,0 +1,63 @@ +getBaseURI( + $currentPath . $path + ); + } +} diff --git a/Route/Attributes/User.php b/Route/Attributes/User.php new file mode 100644 index 0000000..f5ecc10 --- /dev/null +++ b/Route/Attributes/User.php @@ -0,0 +1,12 @@ +doRedirect ? match ($this->getAuthenticationMethod()) { + self::TYPE_ADMIN => $this->admin ? null : $this->dashboardAuthPath, + self::TYPE_USER => $this->user ? null : $this->userAuthPath, + default => null, + } : null; + return $redirect + ? $this->redirect( + $this + ->getView() + ->getBaseURI($redirect) + ->withQuery( + 'redirect=' + . DataNormalizer::normalizeUnixDirectorySeparator($request->getUri()->getPath()) + ) + ) : $this->doAfterBeforeDispatch($request, $method, $arguments); + } + + /** + * @param ServerRequestInterface $request + * @param string $method + * @param ...$arguments + */ + abstract public function doAfterBeforeDispatch( + ServerRequestInterface $request, + string $method, + ...$arguments + ); +} diff --git a/Route/Controllers/AbstractApiController.php b/Route/Controllers/AbstractApiController.php new file mode 100644 index 0000000..56ef7ee --- /dev/null +++ b/Route/Controllers/AbstractApiController.php @@ -0,0 +1,68 @@ +statusCode = 404; + // set result as json if return is not string + $this->asJSON = true; + + // pretty + $env = ContainerHelper::use(Config::class)?->get('environment'); + $env = $env instanceof Config ? $env : null; + if ($env?->get('prettyJson') === true) { + $this->getManager()?->attach( + 'jsonResponder.encodeFlags', + static fn ($flags) => JSON_PRETTY_PRINT|$flags + ); + } + $method = $this->getAuthenticationMethod(); + if ($method === null) { + return null; + } + $jsonResponder = $this->getJsonResponder(); + $match = match ($method) { + self::TYPE_USER => $this->user + ? null + : $jsonResponder->serve(Code::UNAUTHORIZED), + self::TYPE_ADMIN => $this->admin + ? null + : $jsonResponder->serve(Code::UNAUTHORIZED), + default => $this->admin || $this->user + ? null + : $jsonResponder->serve(Code::UNAUTHORIZED), + }; + + return $match ?? $this->doAfterBeforeDispatch( + $request, + $method, + ...$arguments + ); + } + + /** + * @param ServerRequestInterface $request + * @param string $method + * @param ...$arguments + */ + public function doAfterBeforeDispatch( + ServerRequestInterface $request, + string $method, + ...$arguments + ) { + } +} diff --git a/Route/Controllers/AbstractAuthenticationBasedController.php b/Route/Controllers/AbstractAuthenticationBasedController.php new file mode 100644 index 0000000..95c0aa0 --- /dev/null +++ b/Route/Controllers/AbstractAuthenticationBasedController.php @@ -0,0 +1,64 @@ +authenticationMethod; + } + + final public function beforeDispatch(ServerRequestInterface $request, string $method, ...$arguments) + { + $this->authPath = '/'.trim(DataNormalizer::normalizeUnixDirectorySeparator($this->authPath), '/'); + $this->authPath = $this->authPath ?: '/auth'; + $this->userAuthPath = UserAttribute::path($this->authPath); + $this->dashboardAuthPath = DashboardAttribute::path($this->authPath); + $this->users = $this->getModule(Users::class); + $this->user = $this->users->getAdminAccount(); + $this->admin = $this->users->getUserAccount(); + $this->getView()->setParameter('user', $this->user); + $this->getView()->setParameter('admin', $this->admin); + return $this->doBeforeDispatch($request, $method, ...$arguments); + } + + /** + * @param ServerRequestInterface $request + * @param string $method + * @param ...$arguments + */ + abstract public function doBeforeDispatch( + ServerRequestInterface $request, + string $method, + ...$arguments + ); +} diff --git a/Route/Controllers/AbstractDashboardController.php b/Route/Controllers/AbstractDashboardController.php new file mode 100644 index 0000000..36586b0 --- /dev/null +++ b/Route/Controllers/AbstractDashboardController.php @@ -0,0 +1,23 @@ +getMethod() !== 'GET' + || !($path = (reset($reset)?:[])[0]??null) + || !is_string($path) + ) { + return null; + } + if (($end = str_ends_with($path, '//')) || str_starts_with($path, '//')) { + return $this->redirect( + $this->getView()->getBaseURI( + '/'. + trim($path, '/') + . ($end ? '/': '') + ) + ); + } + } +} diff --git a/Traits/UserModuleAssertionTrait.php b/Traits/UserModuleAssertionTrait.php new file mode 100644 index 0000000..a0c5497 --- /dev/null +++ b/Traits/UserModuleAssertionTrait.php @@ -0,0 +1,23 @@ + [ + 'name' => 'auth_user', + 'lifetime' => 0, + 'wildcard' => false + ], + self::ADMIN_MODE => [ + 'name' => 'auth_admin', + 'lifetime' => 0, + 'wildcard' => false + ] + ]; + + private ?User $userAccount = null; + + private ?Admin $adminAccount = null; + + private bool $authProcessed = false; + + private string $currentMode = self::ADMIN_MODE; + + private bool $cookieResolved = false; + + private function resolveCookieName(): self + { + if ($this->cookieResolved) { + return $this; + } + $this->assertObjectUser(); + + $this->cookieResolved = true; + $config = ContainerHelper::use(Config::class, $this->getContainer()); + $cookie = $config->get('cookie'); + if (!$cookie instanceof Config) { + $cookie = new Config(); + $config->set('cookie', $cookie); + } + foreach ($this->cookieNames as $key => $names) { + $cookieData = $cookie->get($key); + $cookieData = $cookieData instanceof Config + ? $cookieData + : new Config(); + // replace + $cookie->set($key, $cookieData); + $cookieName = $cookieData->get('name'); + $cookieName = is_string($cookieName) && trim($cookieName) !== '' + ? trim($cookieName) + : $names['name']; + $cookieName = preg_replace( + '~[^!#$%&\'*+-.^_`|\~a-z0-9]~i', + '', + $cookieName + ); + + $cookieName = $cookieName === '' ? $names['name'] : $cookieName; + $cookieLifetime = $cookieData->get('lifetime'); + $cookieLifetime = is_numeric($cookieLifetime) ? $cookieLifetime : 0; + $cookieLifetime = max((int) $cookieLifetime, 0); + $cookieWildcard = $cookieData->get('wildcard') === true; + $this->cookieNames[$key]['name'] = $cookieName; + $this->cookieNames[$key]['wildcard'] = $cookieWildcard; + $this->cookieNames[$key]['lifetime'] = $cookieLifetime; + } + + return $this; + } + + public function isAuthProcessed(): bool + { + return $this->authProcessed; + } + + private function doProcessAuth(): self + { + if (!$this->request || $this->authProcessed) { + return $this; + } + $this->assertObjectUser(); + $this->authProcessed = true; + $container = $this->getContainer(); + $userAuth = ContainerHelper::service(UserAuth::class, $container); + + $request = $this->getManager()->dispatch('auth.request', $this->request); + $request = $request instanceof ServerRequestInterface + ? $request + : $this->request; + $userAuth->getHashIdentity()->setUserAgent( + $request->getHeaderLine('User-Agent') + ); + + $cookieNames = $this->getCookieNames(); + $cookieParams = $request->getCookieParams(); + $adminCookie = $cookieParams[$cookieNames[self::ADMIN_MODE]['name']]??null; + $adminCookie = !is_string($adminCookie) ? $adminCookie : null; + $userCookie = $cookieParams[$cookieNames[self::USER_MODE]['name']]??null; + $userCookie = is_string($userCookie) ? $userCookie : null; + + $this->userAccount = $userCookie ? $userAuth->getUser( + $userCookie, + $this->getUserEntityFactory() + ) : null; + $this->adminAccount = $adminCookie ? $userAuth->getUser( + $adminCookie, + $this->getAdminEntityFactory() + ) : null; + return $this; + } + + private function createEntityFactoryContainer(): self + { + $this->assertObjectUser(); + $container = $this->getContainer(); + $hasUserEntity = $container->has(UserEntityFactory::class); + $hasAdminEntity = $container->has(AdminEntityFactory::class); + if ($hasUserEntity && $hasAdminEntity) { + return $this; + } + if ($container instanceof SystemContainerInterface) { + if (!$hasUserEntity) { + $container->set(UserEntityFactory::class, UserEntityFactory::class); + } + if (!$hasUserEntity) { + $container->set(AdminEntityFactory::class, AdminEntityFactory::class); + } + return $this; + } + if (!$hasUserEntity) { + $container->set( + UserEntityFactory::class, + fn() => ContainerHelper::resolveCallable(UserEntityFactory::class, $container) + ); + } + if (!$hasAdminEntity) { + $container->set( + AdminEntityFactory::class, + fn() => ContainerHelper::resolveCallable(AdminEntityFactory::class, $container) + ); + } + return $this; + } + + public function getAdminEntityFactory() : AdminEntityFactory + { + try { + return $this + ->createEntityFactoryContainer() + ->getContainer() + ->get(AdminEntityFactory::class); + } catch (Throwable) { + return new AdminEntityFactory( + $this->getConnection() + ); + } + } + + public function getUserEntityFactory() : UserEntityFactory + { + try { + return $this + ->createEntityFactoryContainer() + ->getContainer() + ->get(UserEntityFactory::class); + } catch (Throwable) { + return new UserEntityFactory( + $this->getConnection() + ); + } + } + + public function setAsAdminMode(): void + { + $this->currentMode = self::ADMIN_MODE; + } + + public function setAsUserMode(): void + { + $this->currentMode = self::ADMIN_MODE; + } + + public function getCurrentMode(): string + { + return $this->currentMode; + } + + public function isLoggedIn() : bool + { + return match ($this->getCurrentMode()) { + self::ADMIN_MODE => $this->isAdminLoggedIn(), + self::USER_MODE => $this->isUserLoggedIn(), + default => false + }; + } + + public function getAccount() : User|Admin|null + { + return match ($this->getCurrentMode()) { + self::ADMIN_MODE => $this->getAdminAccount(), + self::USER_MODE => $this->getUserAccount(), + default => null + }; + } + + public function getUserAccount(): ?User + { + return $this->doProcessAuth()->userAccount; + } + + public function getAdminAccount(): ?Admin + { + return $this->doProcessAuth()->adminAccount; + } + + /** + * @return array{ + * user: array{name:string, lifetime: int, wildcard: bool}, + * admin: array{name:string, lifetime: int, wildcard: bool} + * } + */ + public function getCookieNames(): array + { + return $this->resolveCookieName()->cookieNames; + } + + /** + * @param string $type + * @return ?array{name:string, lifetime: int, wildcard: bool} + */ + public function getCookieNameData(string $type): ?array + { + return $this->getCookieNames()[$type]??null; + } + + public function sendAuthCookie( + AbstractUser $userEntity, + ResponseInterface $response + ) : ResponseInterface { + $this->assertObjectUser(); + $container = $this->getContainer(); + $userAuth = ContainerHelper::service(UserAuth::class, $container); + + if (!$userAuth instanceof UserAuth) { + throw new RuntimeException( + 'Can not determine use auth object' + ); + } + $cookieName = $userEntity instanceof Admin + ? 'admin' + : ($userEntity instanceof User ? 'user' : null); + $settings = $cookieName ? $this->getCookieNameData($cookieName) : null; + if ($settings === null) { + throw new RuntimeException( + 'Can not determine cookie type' + ); + } + $request = $this->request??ServerRequest::fromGlobals( + ContainerHelper::use( + ServerRequestFactory::class, + $container + ), + ContainerHelper::use( + StreamFactoryInterface::class, + $container + ) + ); + $domain = $request->getUri()->getHost(); + $newDomain = $this->getManager()?->dispatch( + 'auth.cookieDomain', + $domain + ); + + $domain = is_string($newDomain) && filter_var( + $newDomain, + FILTER_VALIDATE_DOMAIN + ) ? $newDomain : $domain; + + if ($settings['wildcard']) { + $domain = DataNormalizer::splitCrossDomain($domain); + } + $cookie = new SetCookie( + name: $settings['name'], + value: $userAuth->getHashIdentity()->generate($userEntity->getId()), + expiresAt: $settings['lifetime'] === 0 ? 0 : $settings['lifetime'] + time(), + path: '/', + domain: $domain + ); + $cookieObject = $this->getManager()?->dispatch( + 'auth.cookieObject', + $cookie + ); + $cookie = $cookieObject instanceof SetCookie + ? $cookieObject + : $cookie; + return $cookie->appendToResponse($response); + } + + public function isAdminLoggedIn(): bool + { + return $this->getAdminAccount() !== null; + } + + public function isUserLoggedIn(): bool + { + return $this->getUserAccount() !== null; + } + + /** + * @param int $id + * @return ?Admin + */ + public function getAdminById(int $id): ?UserEntityInterface + { + return $this->getAdminEntityFactory()->findById($id); + } + + /** + * @param string $username + * @return ?Admin + */ + public function getAdminByUsername(string $username): ?UserEntityInterface + { + return $this->getAdminEntityFactory()->findByUsername($username); + } + + /** + * @param int $id + * @return ?User + */ + public function getUserById(int $id): ?UserEntityInterface + { + return $this->getUserEntityFactory()->findById($id); + } + + /** + * @param string $username + * @return ?User + */ + public function getUserByUsername(string $username) : ?UserEntityInterface + { + return $this->getUserEntityFactory()->findByUsername($username); + } +} diff --git a/Traits/UserModuleDependsTrait.php b/Traits/UserModuleDependsTrait.php new file mode 100644 index 0000000..29306b0 --- /dev/null +++ b/Traits/UserModuleDependsTrait.php @@ -0,0 +1,35 @@ +option) { + return $this->option; + } + $this->assertObjectUser(); + $this->option = new Option($this); + return $this->option; + } + + public function getSite(): Sites + { + if ($this->site) { + return $this->site; + } + $this->assertObjectUser(); + $this->site = new Sites($this); + return $this->site; + } +} diff --git a/Traits/UserModuleEventTrait.php b/Traits/UserModuleEventTrait.php new file mode 100644 index 0000000..eab9b36 --- /dev/null +++ b/Traits/UserModuleEventTrait.php @@ -0,0 +1,50 @@ +assertObjectUser(); + if (!($manager = $this->getManager()) + || !$manager->insideOf('view.bodyAttributes') + ) { + return $attributes; + } + + $attributes = !is_array($attributes) ? $attributes : []; + $attributes['class'] = DataNormalizer::splitStringToArray($attributes['class']??null)??[]; + $user = $this->getUserAccount(); + $admin = $this->getAdminAccount(); + if (!$user && !$admin) { + return $attributes; + } + $attributes['data-user-logged-in'] = true; + return $attributes; + } + + /** @noinspection PhpUnusedParameterInspection */ + private function eventViewBeforeRender( + $path, + $parameters, + ViewInterface $view + ) { + $this->assertObjectUser(); + if (!($manager = $this->getManager()) + || !$manager->insideOf('view.beforeRender') + ) { + return $path; + } + $view->setParameter('user_user', $this->getUserAccount()); + $view->setParameter('admin_user', $this->getAdminAccount()); + return $path; + } +} diff --git a/Traits/UserModulePermissiveTrait.php b/Traits/UserModulePermissiveTrait.php new file mode 100644 index 0000000..5ac9272 --- /dev/null +++ b/Traits/UserModulePermissiveTrait.php @@ -0,0 +1,96 @@ +permissionResolved) { + return $this; + } + + $this->assertObjectUser(); + $this->permissionResolved = true; + $container = $this->getContainer(); + $connection = $this->getConnection(); + $manager = $this->getManager(); + if (!$container->has(CapabilityEntityFactoryInterface::class)) { + $container->set( + CapabilityEntityFactoryInterface::class, + static fn () => new CapabilityFactory() + ); + } + $hasPermission = $container->has(PermissionInterface::class); + if ($hasPermission) { + $permission = ContainerHelper::getNull( + PermissionInterface::class, + $container + ); + if (!$permission instanceof PermissionInterface) { + $container->remove(PermissionInterface::class); + $hasPermission = false; + } + } + if (!$hasPermission) { + $permission = new PermissionWrapper( + $connection, + $container, + $manager + ); + if ($container instanceof SystemContainerInterface) { + $container->set(PermissionInterface::class, $permission); + } else { + $container->set(PermissionInterface::class, fn () => $permission); + } + } + + $permission ??= ContainerHelper::service( + PermissionInterface::class, + $container + ); + if (!$permission instanceof PermissionWrapper) { + $container->remove(PermissionInterface::class); + $permission = new PermissionWrapper( + $connection, + $container, + $manager, + $permission + ); + $container->set(PermissionInterface::class, $permission); + } + $this->permission = $permission; + if ($this->permission instanceof PermissionWrapper + && !$this->permission->getCapabilityEntityFactory() + ) { + $this->permission->setCapabilityEntityFactory(new CapabilityFactory()); + } + return $this; + } + + /** + * @return PermissionInterface + */ + public function getPermission(): PermissionInterface + { + $container = $this->resolvePermission()->getContainer(); + $permission = ContainerHelper::service(PermissionInterface::class, $container); + return $permission instanceof PermissionWrapper + ? $permission + : $this->permission; + } +} diff --git a/TwigExtensions/UrlExtension.php b/TwigExtensions/UrlExtension.php new file mode 100644 index 0000000..82da4f7 --- /dev/null +++ b/TwigExtensions/UrlExtension.php @@ -0,0 +1,36 @@ + RouteAPI::baseURI($this->engine->getView(), (string) $path) + ), + new TwigFunction( + 'user_api_url', + fn ($path = '') => UserAPI::baseURI($this->engine->getView(), (string) $path) + ), + new TwigFunction( + 'dashboard_api_url', + fn ($path = '') => DashboardAPI::baseURI($this->engine->getView(), (string) $path) + ) + ]; + } +} diff --git a/Users.php b/Users.php index b42f10f..2425048 100644 --- a/Users.php +++ b/Users.php @@ -3,46 +3,28 @@ namespace ArrayAccess\TrayDigita\App\Modules\Users; -use ArrayAccess\TrayDigita\App\Modules\Users\Entities\Admin; -use ArrayAccess\TrayDigita\App\Modules\Users\Entities\User; -use ArrayAccess\TrayDigita\App\Modules\Users\Factory\AdminEntityFactory; -use ArrayAccess\TrayDigita\App\Modules\Users\Factory\CapabilityFactory; -use ArrayAccess\TrayDigita\App\Modules\Users\Factory\UserEntityFactory; -use ArrayAccess\TrayDigita\Auth\Cookie\UserAuth; -use ArrayAccess\TrayDigita\Auth\Roles\Interfaces\PermissionInterface; -use ArrayAccess\TrayDigita\Collection\Config; +use ArrayAccess\TrayDigita\App\Modules\Users\Traits\UserModuleAuthTrait; +use ArrayAccess\TrayDigita\App\Modules\Users\Traits\UserModuleDependsTrait; +use ArrayAccess\TrayDigita\App\Modules\Users\Traits\UserModuleEventTrait; +use ArrayAccess\TrayDigita\App\Modules\Users\Traits\UserModulePermissiveTrait; +use ArrayAccess\TrayDigita\App\Modules\Users\Traits\UserModuleSite; +use ArrayAccess\TrayDigita\App\Modules\Users\TwigExtensions\UrlExtension; use ArrayAccess\TrayDigita\Container\Interfaces\SystemContainerInterface; -use ArrayAccess\TrayDigita\Database\Connection; -use ArrayAccess\TrayDigita\Database\Entities\Abstracts\AbstractUser; -use ArrayAccess\TrayDigita\Database\Entities\Interfaces\CapabilityEntityFactoryInterface; -use ArrayAccess\TrayDigita\Database\Entities\Interfaces\UserEntityInterface; -use ArrayAccess\TrayDigita\Database\Wrapper\PermissionWrapper; -use ArrayAccess\TrayDigita\Exceptions\Runtime\RuntimeException; -use ArrayAccess\TrayDigita\Http\Factory\ServerRequestFactory; use ArrayAccess\TrayDigita\Http\ServerRequest; -use ArrayAccess\TrayDigita\Http\SetCookie; -use ArrayAccess\TrayDigita\Kernel\Interfaces\KernelInterface; -use ArrayAccess\TrayDigita\L10n\Translations\Adapter\Gettext\PoMoAdapter; use ArrayAccess\TrayDigita\Middleware\AbstractMiddleware; use ArrayAccess\TrayDigita\Module\AbstractModule; +use ArrayAccess\TrayDigita\Traits\Database\ConnectionTrait; use ArrayAccess\TrayDigita\Traits\Service\TranslatorTrait; +use ArrayAccess\TrayDigita\Traits\View\ViewTrait; +use ArrayAccess\TrayDigita\Util\Filter\Consolidation; use ArrayAccess\TrayDigita\Util\Filter\ContainerHelper; -use ArrayAccess\TrayDigita\Util\Filter\DataNormalizer; -use ArrayAccess\TrayDigita\View\Interfaces\ViewInterface; -use Doctrine\ORM\Mapping\Driver\AttributeDriver; +use ArrayAccess\TrayDigita\View\Engines\TwigEngine; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestFactoryInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; -use Throwable; -use function filter_var; -use function is_array; -use function is_numeric; -use function is_string; -use function max; -use function preg_replace; -use function trim; -use const FILTER_VALIDATE_DOMAIN; +use const PHP_INT_MAX; use const PHP_INT_MIN; /** @@ -50,219 +32,70 @@ */ final class Users extends AbstractModule { - use TranslatorTrait; + use TranslatorTrait, + ViewTrait, + UserModuleDependsTrait, + ConnectionTrait, + UserModuleEventTrait, + UserModuleAuthTrait, + UserModulePermissiveTrait; protected string $name = 'Users & Auth'; /** * @var int -> very important */ - protected int $priority = PHP_INT_MIN; - - protected PermissionInterface $permission; + protected int $priority = PHP_INT_MIN + 1; private bool $didInit = false; - const ADMIN_MODE = 'admin'; - - const USER_MODE = 'user'; - - private array $cookieNames = [ - self::USER_MODE => [ - 'name' => 'auth_user', - 'lifetime' => 0, - 'wildcard' => false - ], - self::ADMIN_MODE => [ - 'name' => 'auth_admin', - 'lifetime' => 0, - 'wildcard' => false - ] - ]; - - private ?User $userAccount = null; - - private ?Admin $adminAccount = null; - private ?ServerRequestInterface $request = null; - private bool $authProcessed = false; - - private string $currentMode = self::ADMIN_MODE; - public function getName(): string { return $this->translateContext( 'Users & Auth', - 'module', - 'users-module' + 'users-module', + 'module' ); } public function getDescription(): string { return $this->translateContext( - 'Core module that support users & authentication', - 'module', - 'users-module' + 'Module that support users & authentication', + 'users-module', + 'module' ); } protected function doInit(): void { + /** @noinspection DuplicatedCode */ if ($this->didInit) { return; } $this->didInit = true; - foreach ($this->getTranslator()?->getAdapters()??[] as $adapter) { - if ($adapter instanceof PoMoAdapter) { - $adapter->registerDirectory( - __DIR__ .'/Languages', - 'users-module' - ); - } - } - - $this->doRegisterEntities(); - // $this->doResolvePermission(); - // $this->doResolveCookieName(); - $this->doAddMiddleware(); + Consolidation::registerAutoloader(__NAMESPACE__, __DIR__); + $kernel = $this->getKernel(); + $kernel->registerControllerDirectory(__DIR__ .'/Controllers'); + $this->getTranslator()?->registerDirectory('module', __DIR__ . '/Languages'); + $this->getConnection()->registerEntityDirectory(__DIR__.'/Entities'); + $twig = $this->getView()->getEngine('twig'); + ($twig instanceof TwigEngine ? $twig : null) + ->addExtension(new UrlExtension($twig)); + unset($twig); // stop here if config error - if ($this->getKernel()?->getConfigError()) { + if ($kernel->getConfigError()) { return; } - $this->getManager()?->attach( - 'view.beforeRender', - [$this, 'viewBeforeRender'] - ); - $this->getManager()?->attach( - 'view.bodyAttributes', - [$this, 'viewBodyAttributes'] - ); - } - - /** - * Register entities - * @return void - */ - private function doRegisterEntities(): void - { - $metadata = ContainerHelper::use(Connection::class, $this->getContainer()) - ?->getDefaultConfiguration() - ->getMetadataDriverImpl(); - if ($metadata instanceof AttributeDriver) { - $metadata->addPaths([ - __DIR__ . '/Entities' - ]); - } - } - - private function viewBodyAttributes($attributes): array - { - $this->getManager()?->detach( - 'view.bodyAttributes', - [$this, 'viewBodyAttributes'] - ); - - $attributes = !is_array($attributes) ? $attributes : []; - $attributes['class'] = DataNormalizer::splitStringToArray($attributes['class']??null)??[]; - $user = $this->getUserAccount(); - $admin = $this->getAdminAccount(); - if (!$user && !$admin) { - return $attributes; - } - $attributes['data-user-logged-in'] = true; - return $attributes; - } - - /** @noinspection PhpUnusedParameterInspection */ - private function viewBeforeRender( - $path, - $parameters, - ViewInterface $view - ) { - $this->getManager()?->detach( - 'view.beforeRender', - [$this, 'viewBeforeRender'] - ); - $view->setParameter('user_user', $this->getUserAccount()); - $view->setParameter('admin_user', $this->getAdminAccount()); - return $path; - } - - private bool $permissionResolved = false; - - private function doResolvePermission(): self - { - if ($this->permissionResolved) { - return $this; - } - $this->permissionResolved = true; - $container = $this->getContainer(); - $connection = ContainerHelper::use(Connection::class, $container); $manager = $this->getManager(); - if (!$container->has(CapabilityEntityFactoryInterface::class)) { - $container->set( - CapabilityEntityFactoryInterface::class, - static fn () => new CapabilityFactory() - ); - } - $hasPermission = $container->has(PermissionInterface::class); - if ($hasPermission) { - $permission = ContainerHelper::getNull( - PermissionInterface::class, - $container - ); - if (!$permission instanceof PermissionInterface) { - $container->remove(PermissionInterface::class); - $hasPermission = false; - } - } - if (!$hasPermission) { - $permission = new PermissionWrapper( - $connection, - $container, - $manager - ); - if ($container instanceof SystemContainerInterface) { - $container->set(PermissionInterface::class, $permission); - } else { - $container->set(PermissionInterface::class, fn () => $permission); - } - } - - $permission ??= ContainerHelper::service( - PermissionInterface::class, - $container - ); - if (!$permission instanceof PermissionWrapper) { - $container->remove(PermissionInterface::class); - $permission = new PermissionWrapper( - $connection, - $container, - $manager, - $permission - ); - $container->set(PermissionInterface::class, $permission); - } - $this->permission = $permission; - if ($this->permission instanceof PermissionWrapper - && !$this->permission->getCapabilityEntityFactory() - ) { - $this->permission->setCapabilityEntityFactory(new CapabilityFactory()); - } - return $this; - } - - private function doAddMiddleware(): void - { - $container = $this->getContainer(); - ContainerHelper::use( - KernelInterface::class, - $container - )?->getHttpKernel()->addMiddleware( - new class($container, $this) extends AbstractMiddleware { + $manager->attachOnce('view.beforeRender', [$this, 'eventViewBeforeRender']); + $manager->attachOnce('view.bodyAttributes', [$this, 'eventViewBodyAttributes']); + $kernel + ->getHttpKernel() + ->addMiddleware(new class($this->getContainer(), $this) extends AbstractMiddleware { protected int $priority = PHP_INT_MAX - 10; public function __construct( ContainerInterface $container, @@ -276,346 +109,19 @@ protected function doProcess(ServerRequestInterface $request): ServerRequestInte $this->auth->setRequest($request); return $request; } - } - ); - } - - private bool $cookieResolved = false; - - /** - * @return self - */ - private function doResolveCookieName(): self - { - if ($this->cookieResolved) { - return $this; - } - - $this->cookieResolved = true; - $config = ContainerHelper::use(Config::class, $this->getContainer()); - $cookie = $config->get('cookie'); - if (!$cookie instanceof Config) { - $cookie = new Config(); - $config->set('cookie', $cookie); - } - foreach ($this->cookieNames as $key => $names) { - $cookieData = $cookie->get($key); - $cookieData = $cookieData instanceof Config - ? $cookieData - : new Config(); - // replace - $cookie->set($key, $cookieData); - $cookieName = $cookieData->get('name'); - $cookieName = is_string($cookieName) && trim($cookieName) !== '' - ? trim($cookieName) - : $names['name']; - $cookieName = preg_replace( - '~[^!#$%&\'*+-.^_`|\~a-z0-9]~i', - '', - $cookieName - ); - - $cookieName = $cookieName === '' ? $names['name'] : $cookieName; - $cookieLifetime = $cookieData->get('lifetime'); - $cookieLifetime = is_numeric($cookieLifetime) ? $cookieLifetime : 0; - $cookieLifetime = max((int) $cookieLifetime, 0); - $cookieWildcard = $cookieData->get('wildcard') === true; - $this->cookieNames[$key]['name'] = $cookieName; - $this->cookieNames[$key]['wildcard'] = $cookieWildcard; - $this->cookieNames[$key]['lifetime'] = $cookieLifetime; - } - - return $this; + }); } - public function getRequest(): ?ServerRequestInterface + public function getRequest(): ServerRequestInterface { - return $this->request; + return $this->request ??= ServerRequest::fromGlobals( + ContainerHelper::use(ServerRequestFactoryInterface::class, $this->getContainer()), + ContainerHelper::use(StreamFactoryInterface::class, $this->getContainer()) + ); } public function setRequest(ServerRequestInterface $request): void { $this->request = $request; } - - public function isAuthProcessed(): bool - { - return $this->authProcessed; - } - - private function doProcessAuth(): self - { - if (!$this->request || $this->authProcessed) { - return $this; - } - - $this->authProcessed = true; - $container = $this->getContainer(); - $userAuth = ContainerHelper::service(UserAuth::class, $container); - - $request = $this->getManager()->dispatch('auth.request', $this->request); - $request = $request instanceof ServerRequestInterface - ? $request - : $this->request; - $userAuth->getHashIdentity()->setUserAgent( - $request->getHeaderLine('User-Agent') - ); - - $cookieNames = $this->getCookieNames(); - $cookieParams = $request->getCookieParams(); - $adminCookie = $cookieParams[$cookieNames[self::ADMIN_MODE]['name']]??null; - $adminCookie = !is_string($adminCookie) ? $adminCookie : null; - $userCookie = $cookieParams[$cookieNames[self::USER_MODE]['name']]??null; - $userCookie = is_string($userCookie) ? $userCookie : null; - - $this->userAccount = $userCookie ? $userAuth->getUser( - $userCookie, - $this->getUserEntityFactory() - ) : null; - $this->adminAccount = $adminCookie ? $userAuth->getUser( - $adminCookie, - $this->getAdminEntityFactory() - ) : null; - return $this; - } - - private function createEntityFactoryContainer(): self - { - $container = $this->getContainer(); - $hasUserEntity = $container->has(UserEntityFactory::class); - $hasAdminEntity = $container->has(AdminEntityFactory::class); - if ($hasUserEntity && $hasAdminEntity) { - return $this; - } - if ($container instanceof SystemContainerInterface) { - if (!$hasUserEntity) { - $container->set(UserEntityFactory::class, UserEntityFactory::class); - } - if (!$hasUserEntity) { - $container->set(AdminEntityFactory::class, AdminEntityFactory::class); - } - return $this; - } - if (!$hasUserEntity) { - $container->set( - UserEntityFactory::class, - fn() => ContainerHelper::resolveCallable(UserEntityFactory::class, $container) - ); - } - if (!$hasAdminEntity) { - $container->set( - AdminEntityFactory::class, - fn() => ContainerHelper::resolveCallable(AdminEntityFactory::class, $container) - ); - } - return $this; - } - - public function getAdminEntityFactory() : AdminEntityFactory - { - try { - return $this - ->createEntityFactoryContainer() - ->getContainer() - ->get(AdminEntityFactory::class); - } catch (Throwable) { - return new AdminEntityFactory( - ContainerHelper::service(Connection::class, $this->getContainer()) - ); - } - } - - public function getUserEntityFactory() : UserEntityFactory - { - try { - return $this - ->createEntityFactoryContainer() - ->getContainer() - ->get(UserEntityFactory::class); - } catch (Throwable) { - return new UserEntityFactory( - ContainerHelper::service(Connection::class, $this->getContainer()) - ); - } - } - - public function getPermission(): PermissionInterface - { - $container = $this->doResolvePermission()->getContainer(); - $permission = ContainerHelper::service(PermissionInterface::class, $container); - return $permission instanceof PermissionWrapper - ? $permission - : $this->permission; - } - - public function setAsAdminMode(): void - { - $this->currentMode = self::ADMIN_MODE; - } - - public function setAsUserMode(): void - { - $this->currentMode = self::ADMIN_MODE; - } - - public function getCurrentMode(): string - { - return $this->currentMode; - } - - public function isLoggedIn() : bool - { - return match ($this->getCurrentMode()) { - self::ADMIN_MODE => $this->isAdminLoggedIn(), - self::USER_MODE => $this->isUserLoggedIn(), - default => false - }; - } - - public function getAccount() : User|Admin|null - { - return match ($this->getCurrentMode()) { - self::ADMIN_MODE => $this->getAdminAccount(), - self::USER_MODE => $this->getUserAccount(), - default => null - }; - } - - public function getUserAccount(): ?User - { - return $this->doProcessAuth()->userAccount; - } - - public function getAdminAccount(): ?Admin - { - return $this->doProcessAuth()->adminAccount; - } - - /** - * @return array{ - * user: array{name:string, lifetime: int, wildcard: bool}, - * admin: array{name:string, lifetime: int, wildcard: bool} - * } - */ - public function getCookieNames(): array - { - return $this->doResolveCookieName()->cookieNames; - } - - /** - * @param string $type - * @return ?array{name:string, lifetime: int, wildcard: bool} - */ - public function getCookieNameData(string $type): ?array - { - return $this->getCookieNames()[$type]??null; - } - - public function sendAuthCookie( - AbstractUser $userEntity, - ResponseInterface $response - ) : ResponseInterface { - $container = $this->getContainer(); - $userAuth = ContainerHelper::service(UserAuth::class, $container); - - if (!$userAuth instanceof UserAuth) { - throw new RuntimeException( - 'Can not determine use auth object' - ); - } - $cookieName = $userEntity instanceof Admin - ? 'admin' - : ($userEntity instanceof User ? 'user' : null); - $settings = $cookieName ? $this->getCookieNameData($cookieName) : null; - if ($settings === null) { - throw new RuntimeException( - 'Can not determine cookie type' - ); - } - $request = $this->request??ServerRequest::fromGlobals( - ContainerHelper::use( - ServerRequestFactory::class, - $container - ), - ContainerHelper::use( - StreamFactoryInterface::class, - $container - ) - ); - $domain = $request->getUri()->getHost(); - $newDomain = $this->getManager()?->dispatch( - 'auth.cookieDomain', - $domain - ); - - $domain = is_string($newDomain) && filter_var( - $newDomain, - FILTER_VALIDATE_DOMAIN - ) ? $newDomain : $domain; - - if ($settings['wildcard']) { - $domain = DataNormalizer::splitCrossDomain($domain); - } - $cookie = new SetCookie( - name: $settings['name'], - value: $userAuth->getHashIdentity()->generate($userEntity->getId()), - expiresAt: $settings['lifetime'] === 0 ? 0 : $settings['lifetime'] + time(), - path: '/', - domain: $domain - ); - $cookieObject = $this->getManager()?->dispatch( - 'auth.cookieObject', - $cookie - ); - $cookie = $cookieObject instanceof SetCookie - ? $cookieObject - : $cookie; - return $cookie->appendToResponse($response); - } - - public function isAdminLoggedIn(): bool - { - return $this->getAdminAccount() !== null; - } - - public function isUserLoggedIn(): bool - { - return $this->getUserAccount() !== null; - } - - /** - * @param int $id - * @return ?Admin - */ - public function getAdminById(int $id): ?UserEntityInterface - { - return $this->getAdminEntityFactory()->findById($id); - } - - /** - * @param string $username - * @return ?Admin - */ - public function getAdminByUsername(string $username): ?UserEntityInterface - { - return $this->getAdminEntityFactory()->findByUsername($username); - } - - /** - * @param int $id - * @return ?User - */ - public function getUserById(int $id): ?UserEntityInterface - { - return $this->getUserEntityFactory()->findById($id); - } - - /** - * @param string $username - * @return ?User - */ - public function getUserByUsername(string $username) : ?UserEntityInterface - { - return $this->getUserEntityFactory()->findByUsername($username); - } } diff --git a/phpcs.xml b/phpcs.xml index 6874ac2..ebbb256 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -27,6 +27,13 @@ Entities/ Factory/ Users.php + + + + + + +