diff --git a/config/apache/rewrites/users.conf b/config/apache/rewrites/users.conf new file mode 100644 index 00000000..21f53d57 --- /dev/null +++ b/config/apache/rewrites/users.conf @@ -0,0 +1,6 @@ +RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^post$/" +RewriteRule ^/users/([\d]+)$ /users/post.php?user-id=$1 [L] + +RewriteRule ^/users/([^/]+)$ /users/get.php?user-identifier=$1 [L] + +RewriteRule ^/users/([\d]+)/edit$ /users/edit.php?user-id=$1 [L] diff --git a/config/apache/standardebooks.org.conf b/config/apache/standardebooks.org.conf index 43372448..307aeb65 100644 --- a/config/apache/standardebooks.org.conf +++ b/config/apache/standardebooks.org.conf @@ -216,6 +216,7 @@ Define conf_rewrite_root ${web_root}/config/apache/rewrites Include ${conf_rewrite_root}/newsletters.conf Include ${conf_rewrite_root}/artworks.conf Include ${conf_rewrite_root}/polls.conf + Include ${conf_rewrite_root}/users.conf # Specific config for /ebooks///downloads diff --git a/config/apache/standardebooks.test.conf b/config/apache/standardebooks.test.conf index 2615473c..3464d940 100644 --- a/config/apache/standardebooks.test.conf +++ b/config/apache/standardebooks.test.conf @@ -198,6 +198,7 @@ Define conf_rewrite_root ${web_root}/config/apache/rewrites Include ${conf_rewrite_root}/newsletters.conf Include ${conf_rewrite_root}/artworks.conf Include ${conf_rewrite_root}/polls.conf + Include ${conf_rewrite_root}/users.conf # Specific config for /ebooks///downloads diff --git a/config/sql/se/Benefits.sql b/config/sql/se/Benefits.sql index b0910bad..adaabd8e 100644 --- a/config/sql/se/Benefits.sql +++ b/config/sql/se/Benefits.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS `Benefits` ( `CanUploadArtwork` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanReviewArtwork` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanReviewOwnArtwork` tinyint(1) unsigned NOT NULL DEFAULT 0, + `CanEditUsers` tinyint(1) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (`UserId`), KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/Users.sql b/config/sql/se/Users.sql index 8d809eb4..7d8dd2d6 100644 --- a/config/sql/se/Users.sql +++ b/config/sql/se/Users.sql @@ -3,8 +3,13 @@ CREATE TABLE IF NOT EXISTS `Users` ( `Email` varchar(80) DEFAULT NULL, `Name` varchar(255) DEFAULT NULL, `Created` timestamp NOT NULL DEFAULT current_timestamp(), + `Updated` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(), `Uuid` char(36) NOT NULL DEFAULT (uuid()), `PasswordHash` varchar(255) NULL, PRIMARY KEY (`UserId`), - UNIQUE KEY `idxEmail` (`Email`) + UNIQUE KEY `idxEmail` (`Email`,`Uuid`,`UserId`), + UNIQUE KEY `idxUniqueEmail` (`Email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +ALTER TABLE `se`.`Users` +ADD INDEX `idxUniqueEmail` (`Email` ASC) VISIBLE; +; diff --git a/lib/Benefits.php b/lib/Benefits.php index 7bb8aad4..e439b570 100644 --- a/lib/Benefits.php +++ b/lib/Benefits.php @@ -1,9 +1,84 @@ CanUploadArtwork + || + $this->CanReviewArtwork + || + $this->CanReviewOwnArtwork + || + $this->CanEditUsers + ){ + return true; + } + + return false; + } + + protected function GetHasBenefits(): bool{ + if(!isset($this->_HasBenefits)){ + $this->_HasBenefits = false; + + /** @phpstan-ignore-next-line */ + foreach($this as $property => $value){ + $rp = new ReflectionProperty(self::class, $property); + $type = $rp->getType(); + + if($type !== null && ($type instanceof \ReflectionNamedType)){ + $typeName = $type->getName(); + if($typeName == 'bool' && $value == true){ + $this->_HasBenefits = true; + break; + } + } + } + } + + return $this->_HasBenefits; + } + + public function Create(): void{ + Db::Query(' + INSERT into Benefits (UserId, CanAccessFeeds, CanVote, CanBulkDownload, CanUploadArtwork, CanReviewArtwork, CanReviewOwnArtwork, CanEditUsers) + values (?, ?, ?, ?, ?, ?, ?, ?) + ', [$this->UserId, $this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers]); + } + + public function Save(): void{ + Db::Query(' + UPDATE Benefits + set CanAccessFeeds = ?, CanVote = ?, CanBulkDownload = ?, CanUploadArtwork = ?, CanReviewArtwork = ?, CanReviewOwnArtwork = ?, CanEditUsers = ? + where + UserId = ? + ', [$this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers, $this->UserId]); + } + + public function FillFromHttpPost(): void{ + $this->PropertyFromHttp('CanAccessFeeds'); + $this->PropertyFromHttp('CanVote'); + $this->PropertyFromHttp('CanBulkDownload'); + $this->PropertyFromHttp('CanUploadArtwork'); + $this->PropertyFromHttp('CanReviewArtwork'); + $this->PropertyFromHttp('CanReviewOwnArtwork'); + $this->PropertyFromHttp('CanEditUsers'); + } } diff --git a/lib/Enums/PasswordActionType.php b/lib/Enums/PasswordActionType.php new file mode 100644 index 00000000..7b0d2eb4 --- /dev/null +++ b/lib/Enums/PasswordActionType.php @@ -0,0 +1,8 @@ +Url = $url; + + parent::__construct(); + } +} diff --git a/lib/Exceptions/UuidRequiredException.php b/lib/Exceptions/UuidRequiredException.php new file mode 100644 index 00000000..f317eca6 --- /dev/null +++ b/lib/Exceptions/UuidRequiredException.php @@ -0,0 +1,7 @@ +Validate($expectedCaptcha, $receivedCaptcha); - // Do we need to create a user? + // Do we need to create a `User`? try{ $this->User = User::GetByEmail($this->User->Email); } catch(Exceptions\UserNotFoundException){ - // User doesn't exist, create the user + // User doesn't exist, create the `User`. try{ $this->User->Create(); } - catch(Exceptions\UserExistsException){ - // User exists, pass + catch(Exceptions\UserExistsException | Exceptions\InvalidUserException){ + // `User` exists, pass. } } @@ -75,7 +75,7 @@ class NewsletterSubscription{ throw new Exceptions\NewsletterSubscriptionExistsException(); } - // Send the double opt-in confirmation email + // Send the double opt-in confirmation email. $this->SendConfirmationEmail(); } @@ -162,13 +162,27 @@ class NewsletterSubscription{ throw new Exceptions\NewsletterSubscriptionNotFoundException(); } - $result = Db::Query(' + return Db::Query(' SELECT ns.* from NewsletterSubscriptions ns inner join Users u using(UserId) where u.Uuid = ? - ', [$uuid], NewsletterSubscription::class); + ', [$uuid], NewsletterSubscription::class)[0] ?? throw new Exceptions\NewsletterSubscriptionNotFoundException(); + } - return $result[0] ?? throw new Exceptions\NewsletterSubscriptionNotFoundException(); + /** + * @throws Exceptions\NewsletterSubscriptionNotFoundException + */ + public static function GetByUserId(?int $userId): NewsletterSubscription{ + if($userId === null){ + throw new Exceptions\NewsletterSubscriptionNotFoundException(); + } + + return Db::Query(' + SELECT ns.* + from NewsletterSubscriptions ns + inner join Users u using(UserId) + where u.UserId = ? + ', [$userId], NewsletterSubscription::class)[0] ?? throw new Exceptions\NewsletterSubscriptionNotFoundException(); } } diff --git a/lib/Payment.php b/lib/Payment.php index ee983797..365378c2 100644 --- a/lib/Payment.php +++ b/lib/Payment.php @@ -45,18 +45,18 @@ class Payment{ */ public function Create(): void{ if($this->UserId === null){ - // Check if we have to create a new user in the database + // Check if we have to create a new `User` in the database. - // If the User object isn't null, then check if we already have this user in our system + // If the `User` isn't **null**, then check if we already have this user in our system. if($this->User !== null && $this->User->Email !== null){ try{ $user = User::GetByEmail($this->User->Email); - // User exists, use their data + // `User` exists, use their data $user->Name = $this->User->Name; $this->User = $user; - // Update their name in case we have their email (but not name) recorded from a newsletter subscription + // Update their name in case we have their email (but not name) recorded from a newsletter subscription. Db::Query(' UPDATE Users set Name = ? @@ -64,13 +64,13 @@ class Payment{ ', [$this->User->Name, $this->User->UserId]); } catch(Exceptions\UserNotFoundException){ - // User doesn't exist, create it now + // User doesn't exist, create it now. try{ $this->User->Create(); } - catch(Exceptions\UserExistsException){ - // User already exists, pass + catch(Exceptions\UserExistsException | Exceptions\InvalidUserException){ + // `User` already exists, pass. } } diff --git a/lib/User.php b/lib/User.php index 3574518e..8d9131ef 100644 --- a/lib/User.php +++ b/lib/User.php @@ -2,18 +2,26 @@ use Ramsey\Uuid\Uuid; use Safe\DateTimeImmutable; +use function Safe\preg_match; + /** * @property array $Payments - * @property bool $IsRegistered + * @property bool $IsRegistered A user is "registered" if they have an entry in the `Benefits` table; a password is required to log in. * @property Benefits $Benefits + * @property string $Url + * @property bool $IsPatron + * @property ?Patron $Patron + * @property ?NewsletterSubscription $NewsletterSubscription */ class User{ use Traits\Accessor; + use Traits\PropertyFromHttp; public int $UserId; public ?string $Name = null; public ?string $Email = null; public DateTimeImmutable $Created; + public DateTimeImmutable $Updated; public string $Uuid; public ?string $PasswordHash = null; @@ -21,12 +29,60 @@ class User{ /** @var array $_Payments */ protected array $_Payments; protected Benefits $_Benefits; + protected string $_Url; + protected bool $_IsPatron; + protected ?Patron $_Patron; + protected ?NewsletterSubscription $_NewsletterSubscription; // ******* // GETTERS // ******* + protected function GetIsPatron(): bool{ + if(!isset($this->_IsPatron)){ + $this->GetPatron(); + } + + return $this->_IsPatron; + } + + protected function GetNewsletterSubscription(): ?NewsletterSubscription{ + if(!isset($this->_NewsletterSubscription)){ + try{ + $this->_NewsletterSubscription = NewsletterSubscription::GetByUserId($this->UserId); + } + catch(Exceptions\NewsletterSubscriptionNotFoundException){ + $this->_NewsletterSubscription = null; + } + } + + return $this->_NewsletterSubscription; + } + + protected function GetPatron(): ?Patron{ + if(!isset($this->_Patron)){ + try{ + $this->_Patron = Patron::Get($this->UserId); + $this->IsPatron = true; + } + catch(Exceptions\PatronNotFoundException){ + $this->_Patron = null; + $this->IsPatron = false; + } + } + + return $this->_Patron; + } + + protected function GetUrl(): string{ + if(!isset($this->_Url)){ + $this->_Url = '/users/' . $this->UserId; + } + + return $this->_Url; + } + /** * @return array */ @@ -66,7 +122,7 @@ class User{ protected function GetIsRegistered(): ?bool{ if(!isset($this->_IsRegistered)){ - // A user is "registered" if they have a benefits entry in the table. + // A user is "registered" if they have an entry in the `Benefits` table. // This function will fill it out for us. $this->GetBenefits(); } @@ -80,11 +136,61 @@ class User{ // ******* /** + * @throws Exceptions\InvalidUserException + */ + public function Validate(): void{ + $error = new Exceptions\InvalidUserException(); + + if(!isset($this->Email)){ + $error->Add(new Exceptions\EmailRequiredException()); + } + else{ + if(filter_var($this->Email, FILTER_VALIDATE_EMAIL) === false){ + $error->Add(new Exceptions\InvalidEmailException('Email is invalid.')); + } + } + + if(!isset($this->Uuid)){ + $error->Add(new Exceptions\UuidRequiredException()); + } + else{ + if(!preg_match('/^[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}$/', $this->Uuid)){ + $error->Add(new Exceptions\InvalidUuidException()); + } + } + + if(trim($this->Name ?? '') == ''){ + $this->Name = null; + } + + if(trim($this->PasswordHash ?? '') == ''){ + $this->PasswordHash = null; + } + + // Some benefits require this `User` to have a password set. + if($this->Benefits->RequiresPassword && $this->PasswordHash === null){ + $error->Add(new Exceptions\BenefitsRequirePasswordException()); + } + + if($error->HasExceptions){ + throw $error; + } + } + + public function GenerateUuid(): void{ + $uuid = Uuid::uuid4(); + $this->Uuid = $uuid->toString(); + } + + + /** + * @throws Exceptions\InvalidUserException * @throws Exceptions\UserExistsException */ public function Create(?string $password = null): void{ - $uuid = Uuid::uuid4(); - $this->Uuid = $uuid->toString(); + $this->GenerateUuid(); + + $this->Validate(); $this->Created = NOW; @@ -110,6 +216,37 @@ class User{ $this->UserId = Db::GetLastInsertedId(); } + /** + * @throws Exceptions\InvalidUserException + * @throws Exceptions\UserExistsException + */ + public function Save(): void{ + $this->Validate(); + + $this->Updated = NOW; + + try{ + Db::Query(' + UPDATE Users + set Email = ?, Name = ?, Uuid = ?, Updated = ?, PasswordHash = ? + where + UserId = ? + ', [$this->Email, $this->Name, $this->Uuid, $this->Updated, $this->PasswordHash, $this->UserId]); + + if($this->IsRegistered){ + $this->Benefits->Save(); + } + elseif($this->Benefits->HasBenefits){ + $this->Benefits->UserId = $this->UserId; + $this->Benefits->Create(); + $this->IsRegistered = true; + } + } + catch(Exceptions\DuplicateDatabaseKeyException){ + throw new Exceptions\UserExistsException(); + } + } + // *********** // ORM METHODS @@ -130,6 +267,30 @@ class User{ ', [$userId], User::class)[0] ?? throw new Exceptions\UserNotFoundException(); } + /** + * Get a `User` based on either a `UserId`, `Email`, or `Uuid`. + * + * @throws Exceptions\UserNotFoundException + */ + public static function GetByIdentifier(?string $identifier): User{ + if($identifier === null){ + throw new Exceptions\UserNotFoundException(); + } + + if(ctype_digit($identifier)){ + return User::Get(intval($identifier)); + } + elseif(mb_stripos($identifier, '@') !== false){ + return User::GetByEmail($identifier); + } + elseif(mb_stripos($identifier, '-') !== false){ + return User::GetByUuid($identifier); + } + else{ + throw new Exceptions\UserNotFoundException(); + } + } + /** * @throws Exceptions\UserNotFoundException */ @@ -145,6 +306,21 @@ class User{ ', [$email], User::class)[0] ?? throw new Exceptions\UserNotFoundException(); } + /** + * @throws Exceptions\UserNotFoundException + */ + public static function GetByUuid(?string $uuid): User{ + if($uuid === null){ + throw new Exceptions\UserNotFoundException(); + } + + return Db::Query(' + SELECT * + from Users + where Uuid = ? + ', [$uuid], User::class)[0] ?? throw new Exceptions\UserNotFoundException(); + } + /** * Get a `User` if they are considered "registered". * @@ -169,7 +345,7 @@ class User{ ', [$identifier, $identifier], User::class)[0] ?? throw new Exceptions\UserNotFoundException(); if($user->PasswordHash !== null && $password === null){ - // Indicate that a password is required before we log in + // Indicate that a password is required before we log in. throw new Exceptions\PasswordRequiredException(); } @@ -179,4 +355,12 @@ class User{ return $user; } + + public function FillFromHttpPost(): void{ + $this->PropertyFromHttp('Name'); + $this->PropertyFromHttp('Email'); + $this->PropertyFromHttp('Uuid'); + + $this->Benefits->FillFromHttpPost(); + } } diff --git a/templates/ArtworkForm.php b/templates/ArtworkForm.php index 63d87d5b..e668a1fd 100644 --- a/templates/ArtworkForm.php +++ b/templates/ArtworkForm.php @@ -196,5 +196,11 @@ $isEditForm = $isEditForm ?? false; diff --git a/templates/DonationCounter.php b/templates/DonationCounter.php index e39f62e8..35c77dcf 100644 --- a/templates/DonationCounter.php +++ b/templates/DonationCounter.php @@ -41,7 +41,7 @@ $digits = str_split(str_pad((string)$current, 3, "0", STR_PAD_LEFT)) ?>