From 2ef5ce6551aabc4d09aa804ff0ca2d7bc727078f Mon Sep 17 00:00:00 2001 From: Alex Cabal Date: Wed, 29 Jun 2022 16:51:45 -0500 Subject: [PATCH] Add poll system for Patrons Circle --- config/apache/standardebooks.org.conf | 10 +++ config/apache/standardebooks.test.conf | 10 +++ config/phpstan/phpstan.neon | 6 ++ config/sql/se/PollItems.sql | 8 ++ config/sql/se/Polls.sql | 11 +++ config/sql/se/Votes.sql | 6 ++ lib/Constants.php | 7 ++ lib/Exceptions/InvalidPatronException.php | 6 ++ lib/Exceptions/InvalidPollException.php | 6 ++ lib/Exceptions/InvalidPollItemException.php | 6 ++ lib/Exceptions/InvalidUserException.php | 6 ++ lib/Exceptions/PollClosedException.php | 6 ++ lib/Exceptions/PollItemRequiredException.php | 6 ++ lib/Exceptions/ValidationException.php | 6 ++ lib/Exceptions/VoteExistsException.php | 6 ++ lib/Feed.php | 1 + lib/HttpInput.php | 25 ++++++ lib/NewsletterSubscriber.php | 14 ++- lib/OpdsFeed.php | 1 + lib/OrmBase.php | 27 ------ lib/Patron.php | 32 ++++--- lib/Payment.php | 12 +-- lib/Poll.php | 88 +++++++++++++++++++ lib/PollItem.php | 27 ++++++ lib/PropertiesBase.php | 11 ++- lib/RssFeed.php | 5 ++ lib/User.php | 24 ++++- lib/Vote.php | 87 ++++++++++++++++++ .../delete-unconfirmed-newsletter-subscribers | 2 +- scripts/process-pending-payments | 11 +-- templates/DonationProgress.php | 2 +- www/about/index.php | 4 +- www/css/core.css | 88 ++++++++++++++++--- www/newsletter/subscribers/delete.php | 4 +- www/newsletter/subscribers/post.php | 10 +-- www/patrons-circle/index.php | 3 + www/patrons-circle/polls/get.php | 40 +++++++++ www/patrons-circle/polls/votes/index.php | 46 ++++++++++ www/patrons-circle/polls/votes/new.php | 58 ++++++++++++ www/patrons-circle/polls/votes/post.php | 51 +++++++++++ www/patrons-circle/polls/votes/success.php | 23 +++++ www/webhooks/github.php | 2 +- www/webhooks/postmark.php | 6 +- www/webhooks/zoho.php | 5 +- 44 files changed, 717 insertions(+), 98 deletions(-) create mode 100644 config/sql/se/PollItems.sql create mode 100644 config/sql/se/Polls.sql create mode 100644 config/sql/se/Votes.sql create mode 100644 lib/Exceptions/InvalidPatronException.php create mode 100644 lib/Exceptions/InvalidPollException.php create mode 100644 lib/Exceptions/InvalidPollItemException.php create mode 100644 lib/Exceptions/InvalidUserException.php create mode 100644 lib/Exceptions/PollClosedException.php create mode 100644 lib/Exceptions/PollItemRequiredException.php create mode 100644 lib/Exceptions/VoteExistsException.php delete mode 100644 lib/OrmBase.php create mode 100644 lib/Poll.php create mode 100644 lib/PollItem.php create mode 100644 lib/Vote.php create mode 100644 www/patrons-circle/index.php create mode 100644 www/patrons-circle/polls/get.php create mode 100644 www/patrons-circle/polls/votes/index.php create mode 100644 www/patrons-circle/polls/votes/new.php create mode 100644 www/patrons-circle/polls/votes/post.php create mode 100644 www/patrons-circle/polls/votes/success.php diff --git a/config/apache/standardebooks.org.conf b/config/apache/standardebooks.org.conf index 5ea45687..2c9241ee 100644 --- a/config/apache/standardebooks.org.conf +++ b/config/apache/standardebooks.org.conf @@ -258,6 +258,16 @@ Define webroot /standardebooks.org/web # Newsletter RewriteRule ^/newsletter$ /newsletter/subscribers/new.php RewriteRule ^/newsletter/subscribers/([^\./]+?)/(delete|confirm)$ /newsletter/subscribers/$2.php?uuid=$1 + + # Polls + RewriteRule ^/patrons-circle/polls/([^/]+)$ /patrons-circle/polls/get.php?pollurlname=$1 + RewriteRule ^/patrons-circle/polls/([^/]+)/votes/(new|success)$ /patrons-circle/polls/votes/$2.php?pollurlname=$1 + + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^get$/" + RewriteRule ^/patrons-circle/polls/([^/]+)/votes$ /patrons-circle/polls/votes/index.php?pollurlname=$1 + + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^post$/" + RewriteRule ^/patrons-circle/polls/([^/]+)/votes$ /patrons-circle/polls/votes/post.php?pollurlname=$1 diff --git a/config/apache/standardebooks.test.conf b/config/apache/standardebooks.test.conf index 4eb76e9b..dc073862 100644 --- a/config/apache/standardebooks.test.conf +++ b/config/apache/standardebooks.test.conf @@ -257,4 +257,14 @@ Define webroot /standardebooks.org/web # Newsletter RewriteRule ^/newsletter$ /newsletter/subscribers/new.php RewriteRule ^/newsletter/subscribers/([^\./]+?)/(delete|confirm)$ /newsletter/subscribers/$2.php?uuid=$1 + + # Polls + RewriteRule ^/patrons-circle/polls/([^/]+)$ /patrons-circle/polls/get.php?pollurlname=$1 + RewriteRule ^/patrons-circle/polls/([^/]+)/votes/(new|success)$ /patrons-circle/polls/votes/$2.php?pollurlname=$1 + + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^get$/" + RewriteRule ^/patrons-circle/polls/([^/]+)/votes$ /patrons-circle/polls/votes/index.php?pollurlname=$1 + + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^post$/" + RewriteRule ^/patrons-circle/polls/([^/]+)/votes$ /patrons-circle/polls/votes/post.php?pollurlname=$1 diff --git a/config/phpstan/phpstan.neon b/config/phpstan/phpstan.neon index 2931b503..a1916667 100644 --- a/config/phpstan/phpstan.neon +++ b/config/phpstan/phpstan.neon @@ -19,6 +19,12 @@ parameters: - '#Method Ebook::NullIfEmpty\(\) has parameter \$elements with no type specified.#' - '#Method HttpInput::GetHttpVar\(\) has no return type specified.#' - '#Method HttpInput::GetHttpVar\(\) has parameter \$default with no type specified.#' + + # Ignore errors caused by access to our PropertiesBase pattern + - '#Access to protected property .+#' + + # Ignore symbols that PHPStan can't find + - '#Constant EMAIL_SMTP_USERNAME not found.#' level: 7 paths: diff --git a/config/sql/se/PollItems.sql b/config/sql/se/PollItems.sql new file mode 100644 index 00000000..ea46b8a7 --- /dev/null +++ b/config/sql/se/PollItems.sql @@ -0,0 +1,8 @@ +CREATE TABLE `PollItems` ( + `PollItemId` int(10) unsigned NOT NULL AUTO_INCREMENT, + `PollId` int(10) unsigned NOT NULL, + `Name` varchar(255) NOT NULL, + `Description` text DEFAULT NULL, + `SortOrder` tinyint(3) unsigned NOT NULL, + PRIMARY KEY (`PollItemId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/Polls.sql b/config/sql/se/Polls.sql new file mode 100644 index 00000000..aabaafc7 --- /dev/null +++ b/config/sql/se/Polls.sql @@ -0,0 +1,11 @@ +CREATE TABLE `Polls` ( + `PollId` int(11) unsigned NOT NULL AUTO_INCREMENT, + `Created` datetime NOT NULL, + `Name` varchar(255) NOT NULL, + `UrlName` varchar(255) NOT NULL, + `Description` text DEFAULT NULL, + `Start` datetime DEFAULT NULL, + `End` datetime DEFAULT NULL, + PRIMARY KEY (`PollId`), + UNIQUE KEY `idxUnique` (`UrlName`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/config/sql/se/Votes.sql b/config/sql/se/Votes.sql new file mode 100644 index 00000000..622e5e8d --- /dev/null +++ b/config/sql/se/Votes.sql @@ -0,0 +1,6 @@ +CREATE TABLE `Votes` ( + `UserId` int(11) unsigned NOT NULL, + `PollItemId` int(11) unsigned NOT NULL, + `Created` datetime NOT NULL, + UNIQUE KEY `idxUnique` (`PollItemId`,`UserId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/lib/Constants.php b/lib/Constants.php index d98fad2c..fb9a6a2b 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -59,6 +59,13 @@ const HTTP_VAR_BOOL = 2; const HTTP_VAR_DEC = 3; const HTTP_VAR_ARRAY = 4; +const HTTP_GET = 0; +const HTTP_POST = 1; +const HTTP_PATCH = 2; +const HTTP_PUT = 3; +const HTTP_DELETE = 4; +const HTTP_HEAD = 5; + const VIEW_GRID = 'grid'; const VIEW_LIST = 'list'; diff --git a/lib/Exceptions/InvalidPatronException.php b/lib/Exceptions/InvalidPatronException.php new file mode 100644 index 00000000..b6920f90 --- /dev/null +++ b/lib/Exceptions/InvalidPatronException.php @@ -0,0 +1,6 @@ +Add($exception); + } + } + public function __toString(): string{ $output = ''; foreach($this->Exceptions as $exception){ diff --git a/lib/Exceptions/VoteExistsException.php b/lib/Exceptions/VoteExistsException.php new file mode 100644 index 00000000..2d59b08f --- /dev/null +++ b/lib/Exceptions/VoteExistsException.php @@ -0,0 +1,6 @@ +Uuid = $uuid->toString(); + $this->Timestamp = new DateTime(); try{ - Db::Query('insert into NewsletterSubscribers (Email, Uuid, FirstName, LastName, IsConfirmed, IsSubscribedToNewsletter, IsSubscribedToSummary, Timestamp) values (?, ?, ?, ?, ?, ?, ?, utc_timestamp());', [$this->Email, $this->Uuid, $this->FirstName, $this->LastName, false, $this->IsSubscribedToNewsletter, $this->IsSubscribedToSummary]); + Db::Query('INSERT into NewsletterSubscribers (Email, Uuid, FirstName, LastName, IsConfirmed, IsSubscribedToNewsletter, IsSubscribedToSummary, Timestamp) values (?, ?, ?, ?, ?, ?, ?, ?);', [$this->Email, $this->Uuid, $this->FirstName, $this->LastName, false, $this->IsSubscribedToNewsletter, $this->IsSubscribedToSummary, $this->Timestamp]); } catch(PDOException $ex){ if($ex->errorInfo[1] == 1062){ @@ -53,11 +55,11 @@ class NewsletterSubscriber extends PropertiesBase{ } public function Confirm(): void{ - Db::Query('update NewsletterSubscribers set IsConfirmed = true where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]); + Db::Query('UPDATE NewsletterSubscribers set IsConfirmed = true where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]); } public function Delete(): void{ - Db::Query('delete from NewsletterSubscribers where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]); + Db::Query('DELETE from NewsletterSubscribers where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]); } public function Validate(): void{ @@ -77,11 +79,7 @@ class NewsletterSubscriber extends PropertiesBase{ } public static function Get(string $uuid): NewsletterSubscriber{ - if($uuid == ''){ - throw new Exceptions\InvalidNewsletterSubscriberException(); - } - - $subscribers = Db::Query('select * from NewsletterSubscribers where Uuid = ?;', [$uuid], 'NewsletterSubscriber'); + $subscribers = Db::Query('SELECT * from NewsletterSubscribers where Uuid = ?;', [$uuid], 'NewsletterSubscriber'); if(sizeof($subscribers) == 0){ throw new Exceptions\InvalidNewsletterSubscriberException(); diff --git a/lib/OpdsFeed.php b/lib/OpdsFeed.php index 8177216c..40f0db75 100644 --- a/lib/OpdsFeed.php +++ b/lib/OpdsFeed.php @@ -1,4 +1,5 @@ $value){ - if(substr($property, strlen($property) - 5) == 'Cache'){ - $property = substr($property, 0, strlen($property) - 5); - $object->$property = $value; - } - else{ - $object->$property = $value; - } - } - - return $object; - } - - public static function FromRow(array $row): Object{ - return self::FillObject(new static(), $row); - } -} diff --git a/lib/Patron.php b/lib/Patron.php index 5ad60d84..a06df341 100644 --- a/lib/Patron.php +++ b/lib/Patron.php @@ -10,22 +10,30 @@ class Patron extends PropertiesBase{ public $Timestamp = null; public $DeactivatedTimestamp = null; - public static function Get(int $userId): ?Patron{ - $result = Db::Query('select * from Patrons where UserId = ?', [$userId], 'Patron'); + public static function Get(?int $userId): Patron{ + $result = Db::Query('SELECT * from Patrons where UserId = ?', [$userId], 'Patron'); - return $result[0] ?? null; - } - - protected function GetUser(): ?User{ - if($this->User === null && $this->UserId !== null){ - $this->User = User::Get($this->UserId); + if(sizeof($result) == 0){ + throw new Exceptions\InvalidPatronException(); } - return $this->User; + return $result[0]; + } + + public static function GetByEmail(?string $email): Patron{ + $result = Db::Query('SELECT p.* from Patrons p inner join Users u on p.UserId = u.UserId where u.Email = ?', [$email], 'Patron'); + + if(sizeof($result) == 0){ + throw new Exceptions\InvalidPatronException(); + } + + return $result[0]; } public function Create(bool $sendEmail = true): void{ - Db::Query('insert into Patrons (Timestamp, UserId, IsAnonymous, AlternateName, IsSubscribedToEmail) values(utc_timestamp(), ?, ?, ?, ?);', [$this->UserId, $this->IsAnonymous, $this->AlternateName, $this->IsSubscribedToEmail]); + $this->Timestamp = new DateTime(); + + Db::Query('INSERT into Patrons (Timestamp, UserId, IsAnonymous, AlternateName, IsSubscribedToEmail) values(?, ?, ?, ?, ?);', [$this->Timestamp, $this->UserId, $this->IsAnonymous, $this->AlternateName, $this->IsSubscribedToEmail]); if($sendEmail){ $this->SendWelcomeEmail(); @@ -33,7 +41,7 @@ class Patron extends PropertiesBase{ } public function Reactivate(bool $sendEmail = true): void{ - Db::Query('update Patrons set Timestamp = utc_timestamp(), DeactivatedTimestamp = null, IsAnonymous = ?, IsSubscribedToEmail = ?, AlternateName = ? where UserId = ?;', [$this->IsAnonymous, $this->IsSubscribedToEmail, $this->AlternateName, $this->UserId]); + Db::Query('UPDATE Patrons set Timestamp = utc_timestamp(), DeactivatedTimestamp = null, IsAnonymous = ?, IsSubscribedToEmail = ?, AlternateName = ? where UserId = ?;', [$this->IsAnonymous, $this->IsSubscribedToEmail, $this->AlternateName, $this->UserId]); $this->Timestamp = new DateTime(); $this->DeactivatedTimestamp = null; @@ -43,7 +51,7 @@ class Patron extends PropertiesBase{ } private function SendWelcomeEmail(): void{ - $this->GetUser(); + $this->__get('User'); if($this->User !== null){ $em = new Email(); $em->To = $this->User->Email; diff --git a/lib/Payment.php b/lib/Payment.php index 1611e4d7..94341bc0 100644 --- a/lib/Payment.php +++ b/lib/Payment.php @@ -11,21 +11,13 @@ class Payment extends PropertiesBase{ public $Fee; public $IsRecurring; - protected function GetUser(): ?User{ - if($this->User === null && $this->UserId !== null){ - $this->User = User::Get($this->UserId); - } - - return $this->User; - } - public function Create(): void{ if($this->UserId === null){ // 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($this->User !== null && $this->User->Email !== null){ - $result = Db::Query('select * from Users where Email = ?', [$this->User->Email], 'User'); + $result = Db::Query('SELECT * from Users where Email = ?', [$this->User->Email], 'User'); if(sizeof($result) == 0){ // User doesn't exist, create it now @@ -41,7 +33,7 @@ class Payment extends PropertiesBase{ } try{ - Db::Query('insert into Payments (UserId, Timestamp, ChannelId, TransactionId, Amount, Fee, IsRecurring) values(?, ?, ?, ?, ?, ?, ?);', [$this->UserId, $this->Timestamp, $this->ChannelId, $this->TransactionId, $this->Amount, $this->Fee, $this->IsRecurring]); + Db::Query('INSERT into Payments (UserId, Timestamp, ChannelId, TransactionId, Amount, Fee, IsRecurring) values(?, ?, ?, ?, ?, ?, ?);', [$this->UserId, $this->Timestamp, $this->ChannelId, $this->TransactionId, $this->Amount, $this->Fee, $this->IsRecurring]); } catch(PDOException $ex){ if($ex->errorInfo[1] == 1062){ diff --git a/lib/Poll.php b/lib/Poll.php new file mode 100644 index 00000000..a27e540d --- /dev/null +++ b/lib/Poll.php @@ -0,0 +1,88 @@ +Url === null){ + $this->Url = '/patrons-circle/polls/' . $this->UrlName; + } + + return $this->Url; + } + + protected function GetVoteCount(): int{ + if($this->VoteCount === null){ + $this->VoteCount = (Db::Query('select count(*) as VoteCount from Votes v inner join PollItems pi on v.PollItemId = pi.PollItemId where pi.PollId = ?', [$this->PollId]))[0]->VoteCount; + } + + return $this->VoteCount; + } + + /** + * @return array + */ + protected function GetPollItems(): array{ + if($this->PollItems === null){ + $this->PollItems = Db::Query('SELECT * from PollItems where PollId = ? order by SortOrder asc', [$this->PollId], 'PollItem'); + } + + return $this->PollItems; + } + + /** + * @return array + */ + protected function GetPollItemsByWinner(): array{ + if($this->PollItemsByWinner === null){ + $this->__get('PollItems'); + $this->PollItemsByWinner = $this->PollItems; + usort($this->PollItemsByWinner, function(PollItem $a, PollItem $b){ return $a->VoteCount <=> $b->VoteCount; }); + + $this->PollItemsByWinner = array_reverse($this->PollItemsByWinner); + } + + return $this->PollItemsByWinner; + } + + public function IsActive(): bool{ + $now = new DateTime(); + if( ($this->Start !== null && $this->Start > $now) || ($this->End !== null && $this->End < $now)){ + return false; + } + + return true; + } + + public static function Get(?int $pollId): Poll{ + $result = Db::Query('SELECT * from Polls where PollId = ?', [$pollId], 'Poll'); + + if(sizeof($result) == 0){ + throw new Exceptions\InvalidPollException(); + } + + return $result[0]; + } + + public static function GetByUrlName(?string $urlName): Poll{ + $result = Db::Query('SELECT * from Polls where UrlName = ?', [$urlName], 'Poll'); + + if(sizeof($result) == 0){ + throw new Exceptions\InvalidPollException(); + } + + return $result[0]; + } +} diff --git a/lib/PollItem.php b/lib/PollItem.php new file mode 100644 index 00000000..d22878ac --- /dev/null +++ b/lib/PollItem.php @@ -0,0 +1,27 @@ +VoteCount === null){ + $this->VoteCount = (Db::Query('select count(*) as VoteCount from Votes v inner join PollItems pi on v.PollItemId = pi.PollItemId where pi.PollItemId = ?', [$this->PollItemId]))[0]->VoteCount; + } + + return $this->VoteCount; + } + + public static function Get(?int $pollItemId): PollItem{ + $result = Db::Query('SELECT * from PollItems where PollItemId = ?', [$pollItemId], 'PollItem'); + + if(sizeof($result) == 0){ + throw new Exceptions\InvalidPollItemException(); + } + + return $result[0]; + } +} diff --git a/lib/PropertiesBase.php b/lib/PropertiesBase.php index 90101971..9de57b49 100644 --- a/lib/PropertiesBase.php +++ b/lib/PropertiesBase.php @@ -1,7 +1,7 @@ $function(); } + elseif(property_exists($this, $var . 'Id') && method_exists($var, 'Get')){ + // If our object has an VarId attribute, and the Var class also has a ::Get method, + // call it and return the result + if($this->$var === null && $this->{$var . 'Id'} !== null){ + $this->$var = $var::Get($this->{$var . 'Id'}); + } + + return $this->$var; + } elseif(substr($var, 0, 7) == 'Display'){ // If we're asked for a DisplayXXX property and the getter doesn't exist, format as escaped HTML. if($this->$var === null){ diff --git a/lib/RssFeed.php b/lib/RssFeed.php index c84ca049..5855e947 100644 --- a/lib/RssFeed.php +++ b/lib/RssFeed.php @@ -1,4 +1,9 @@ Uuid = $uuid->toString(); + $this->Timestamp = new DateTime(); try{ - Db::Query('insert into Users (Email, Name, Uuid, Timestamp) values (?, ?, ?, utc_timestamp());', [$this->Email, $this->Name, $this->Uuid]); + Db::Query('INSERT into Users (Email, Name, Uuid, Timestamp) values (?, ?, ?, ?);', [$this->Email, $this->Name, $this->Uuid, $this->Timestamp]); } catch(PDOException $ex){ if($ex->errorInfo[1] == 1062){ diff --git a/lib/Vote.php b/lib/Vote.php new file mode 100644 index 00000000..9c243294 --- /dev/null +++ b/lib/Vote.php @@ -0,0 +1,87 @@ +Url === null){ + $this->Url = '/patrons-circle/polls/' . $this->PollItem->Poll->Url . '/votes/' . $this->UserId; + } + + return $this->Url; + } + + protected function Validate(): void{ + $error = new Exceptions\ValidationException(); + + if($this->UserId === null){ + $error->Add(new Exceptions\InvalidPatronException()); + } + + if($this->PollItemId === null){ + $error->Add(new Exceptions\PollItemRequiredException()); + } + else{ + $this->__get('PollItem'); + if($this->PollItem === null){ + $error->Add(new Exceptions\InvalidPollException()); + } + } + + if(!$this->PollItem->Poll->IsActive()){ + $error->Add(new Exceptions\PollClosedException()); + } + + if(!$error->HasExceptions){ + // Basic sanity checks done, now check if we've already voted + // in this poll + + $this->__get('User'); + if($this->User === null){ + $error->Add(new Exceptions\InvalidPatronException()); + } + else{ + // Do we already have a vote for this poll, from this user? + if( (Db::Query(' + SELECT count(*) as VoteCount from Votes v inner join + (select PollItemId from PollItems pi inner join Polls p on pi.PollId = p.PollId) x + on v.PollItemId = x.PollItemId where v.UserId = ?', [$this->UserId]))[0]->VoteCount > 0){ + $error->Add(new Exceptions\VoteExistsException()); + } + } + } + + if($error->HasExceptions){ + throw $error; + } + } + + public function Create(?string $email = null): void{ + if($email !== null){ + try{ + $patron = Patron::GetByEmail($email); + $this->UserId = $patron->UserId; + $this->User = $patron->User; + } + catch(Exceptions\InvalidPatronException $ex){ + // Can't validate patron email - do nothing for now, + // this will be caught later when we validate the vote during creation. + // Save the email in the User object in case we want it later, + // for example prefilling the 'create' form after an error is returned. + $this->User = new User(); + $this->User->Email = $email; + } + } + + $this->Validate(); + $this->Created = new DateTime(); + Db::Query('INSERT into Votes (UserId, PollItemId, Created) values (?, ?, ?)', [$this->UserId, $this->PollItemId, $this->Created]); + } +} diff --git a/scripts/delete-unconfirmed-newsletter-subscribers b/scripts/delete-unconfirmed-newsletter-subscribers index ed2bc956..35a8a530 100644 --- a/scripts/delete-unconfirmed-newsletter-subscribers +++ b/scripts/delete-unconfirmed-newsletter-subscribers @@ -3,5 +3,5 @@ require_once('/standardebooks.org/web/lib/Core.php'); // Delete unconfirmed newsletter subscribers who are more than a week old -Db::Query('delete from NewsletterSubscribers where IsConfirmed = false and datediff(utc_timestamp(), Timestamp) >= 7'); +Db::Query('DELETE from NewsletterSubscribers where IsConfirmed = false and datediff(utc_timestamp(), Timestamp) >= 7'); ?> diff --git a/scripts/process-pending-payments b/scripts/process-pending-payments index 25422d77..abbb496d 100755 --- a/scripts/process-pending-payments +++ b/scripts/process-pending-payments @@ -160,9 +160,10 @@ try{ if(($payment->IsRecurring && $payment->Amount >= 10 && $payment->Timestamp >= $lastMonth) || ($payment->Amount >= 100 && $payment->Timestamp >= $lastYear)){ // This payment is eligible for the Patrons Circle. // Are we already a patron? - $patron = Patron::Get($payment->UserId); - - if($patron === null){ + try{ + $patron = Patron::Get($payment->UserId); + } + catch(Exceptions\InvalidPatronException $ex){ // Not a patron yet, add them to the Patrons Circle $patron = new Patron(); $patron->UserId = $payment->UserId; @@ -194,7 +195,7 @@ try{ else{ // Not a patron; send a thank you email anyway, but only if this is a non-recurring donation, // or if it's their very first recurring donation - $previousPaymentCount = (Db::Query('select count(*) as PreviousPaymentCount from Payments where UserId = ? and IsRecurring = true', [$payment->UserId]))[0]->PreviousPaymentCount; + $previousPaymentCount = (Db::Query('SELECT count(*) as PreviousPaymentCount from Payments where UserId = ? and IsRecurring = true', [$payment->UserId]))[0]->PreviousPaymentCount; // We just added a payment to the system, so if this is their very first recurring payment, we expect the count to be exactly 1 if(!$payment->IsRecurring || $previousPaymentCount == 1){ @@ -210,7 +211,7 @@ try{ } } - Db::Query('delete from PendingPayments where TransactionId = ?;', [$pendingPayment->TransactionId]); + Db::Query('DELETE from PendingPayments where TransactionId = ?;', [$pendingPayment->TransactionId]); $log->Write('Donation processed.'); } diff --git a/templates/DonationProgress.php b/templates/DonationProgress.php index 82f2ab4a..ec42a20f 100644 --- a/templates/DonationProgress.php +++ b/templates/DonationProgress.php @@ -9,7 +9,7 @@ $startDate = new DateTime('2022-07-01'); $endDate = new DateTime('2022-07-31'); $autoHide = $autoHide ?? true; $showDonateButton = $showDonateButton ?? true; -$current = (Db::Query('select count(*) as PatronCount from Patrons where Timestamp >= ?', [$startDate]))[0]->PatronCount; +$current = (Db::Query('SELECT count(*) as PatronCount from Patrons where Timestamp >= ?', [$startDate]))[0]->PatronCount; $target = 70; $stretchCurrent = 0; $stretchTarget = 20; diff --git a/www/about/index.php b/www/about/index.php index 4a1dfd85..48e1be00 100644 --- a/www/about/index.php +++ b/www/about/index.php @@ -7,7 +7,7 @@ $anonymousPatronCount = 0; // Get the Patrons Circle and try to sort by last name ascending // See for Unicode character properties -$patronsCircle = Db::Query('select if(p.AlternateName is not null, p.AlternateName, u.Name) as SortedName +$patronsCircle = Db::Query('SELECT if(p.AlternateName is not null, p.AlternateName, u.Name) as SortedName from Patrons p inner join Users u on p.UserId = u.UserId where @@ -16,7 +16,7 @@ $patronsCircle = Db::Query('select if(p.AlternateName is not null, p.AlternateNa order by regexp_substr(SortedName, "[\\\p{Lu}][\\\p{L}\-]+$") asc; '); -$anonymousPatronCount = Db::Query('select sum(cnt) as AnonymousPatronCount +$anonymousPatronCount = Db::Query('SELECT sum(cnt) as AnonymousPatronCount from ( ( diff --git a/www/css/core.css b/www/css/core.css index 97109502..c04e09ac 100644 --- a/www/css/core.css +++ b/www/css/core.css @@ -1836,6 +1836,16 @@ main.ebooks nav ol li.highlighted:nth-last-child(2)::after{ grid-template-columns: 1fr; } +.button-row.narrow{ + display: flex; + justify-content: center; + flex-wrap: wrap; +} + +.button-row:last-child{ + margin-bottom: 0; +} + .masthead h2 + section > h3{ margin-top: 0; } @@ -2047,14 +2057,16 @@ article.ebook h2 + section > h3:first-of-type{ left: -5000px; } +form[action*="/polls/"], form[action="/newsletter/subscribers"]{ display: grid; - grid-gap: 1rem; + grid-gap: 2rem; grid-template-columns: 1fr 1fr; margin-top: 1rem; margin-bottom: 0; } +form[action*="/polls/"] label.email, form[action="/newsletter/subscribers"] label.email, form[action="/newsletter/subscribers"] label.captcha{ grid-column: 1 / span 2; @@ -2070,18 +2082,19 @@ form[action="/newsletter/subscribers"] label.captcha div input{ align-self: center; } -form[action="/newsletter/subscribers"] ul{ +form fieldset ul{ list-style: none; } +form[action*="/polls/"] button, form[action="/newsletter/subscribers"] button{ grid-column: 2; justify-self: end; margin-left: 0; } +form[action*="/polls/"] fieldset, form[action="/newsletter/subscribers"] fieldset{ - margin-top: 1rem; grid-column: 1 / span 2; } @@ -2096,15 +2109,25 @@ fieldset p{ label.checkbox{ display: inline-flex; - align-items: center; + align-items: flex-start; text-align: left; line-height: 1; + cursor: pointer; } label.checkbox input{ margin-right: .25rem; } +label.checkbox span{ + display: block; +} + +label.checkbox span > span{ + line-height: 1.6; + margin-top: .25rem; +} + article.step-by-step-guide ol ol{ margin-left: 1.2rem; list-style: decimal; @@ -2130,19 +2153,12 @@ aside header{ font-size: 1.5rem; } +.meter, .progress{ position: relative; font-size: 0; } -.progress > div{ - position: absolute; - width: 100%; - height: 100%; - display: flex; - justify-content: center; -} - .donation a.button{ display: inline-block; white-space: normal; @@ -2215,6 +2231,7 @@ aside header{ hyphens: auto; } +.meter p, .progress p{ font-size: 1rem; font-family: "League Spartan", Arial, sans-serif; @@ -2251,7 +2268,14 @@ aside header{ font-size: .75rem; } +.meter > div, .progress > div{ + position: absolute; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + /* Animate the div instead of the bar itself, because animating the bar triggers an FF bug that causes infinite requsts to stripes.svg */ background: url("/images/stripes.svg") transparent; @@ -2260,6 +2284,7 @@ aside header{ z-index: 3; } +meter, progress{ -webkit-appearance: none; appearance: none; @@ -2280,6 +2305,7 @@ progress::-webkit-progress-value{ box-shadow: 1px 0 1px rgba(0, 0, 0, .25); } +meter::-moz-meter-bar, progress::-moz-progress-bar{ background: var(--button); box-shadow: 1px 0 1px rgba(0, 0, 0, .25); @@ -2507,6 +2533,33 @@ ul.feed p{ word-break: break-word; } +.center-notice{ + text-align: center; + font-style: italic; +} + +.votes{ + margin-top: 2rem; +} + +.votes td:first-child{ + font-weight: bold; +} + +.votes td{ + width: 50%; + padding: 1rem; + text-align: left; +} + +.votes tr:first-child td{ + padding-top: 0; +} + +.votes tr:last-child td{ + padding-bottom: 0; +} + @media (hover: none) and (pointer: coarse){ /* target ipads and smartphones without a mouse */ /* For iPad, unset the height so it matches the other elements */ select[multiple]{ @@ -3016,6 +3069,17 @@ ul.feed p{ form[action="/settings"] select{ width: 100%; } + + .votes tr, + .votes tr td{ + display: block; + width: 100%; + padding: 0; + } + + .votes tr + tr{ + margin-top: 2rem; + } } @media(max-width: 470px){ diff --git a/www/newsletter/subscribers/delete.php b/www/newsletter/subscribers/delete.php index fcfc9252..8d6dd0a9 100644 --- a/www/newsletter/subscribers/delete.php +++ b/www/newsletter/subscribers/delete.php @@ -3,11 +3,11 @@ require_once('Core.php'); use function Safe\preg_match; -$requestType = preg_match('/\btext\/html\b/ius', $_SERVER['HTTP_ACCEPT']) ? WEB : REST; +$requestType = HttpInput::RequestType(); try{ // We may use GET if we're called from an unsubscribe link in an email - if(!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'GET'])){ + if(!in_array(HttpInput::RequestMethod(), [HTTP_DELETE, HTTP_GET])){ throw new Exceptions\InvalidRequestException(); } diff --git a/www/newsletter/subscribers/post.php b/www/newsletter/subscribers/post.php index 270108cf..73785509 100644 --- a/www/newsletter/subscribers/post.php +++ b/www/newsletter/subscribers/post.php @@ -4,14 +4,14 @@ require_once('Core.php'); use function Safe\preg_match; use function Safe\session_unset; -if($_SERVER['REQUEST_METHOD'] != 'POST'){ +if(HttpInput::RequestMethod() != HTTP_POST){ http_response_code(405); exit(); } session_start(); -$requestType = preg_match('/\btext\/html\b/ius', $_SERVER['HTTP_ACCEPT']) ? WEB : REST; +$requestType = HttpInput::RequestType(); $subscriber = new NewsletterSubscriber(); @@ -39,11 +39,7 @@ try{ $captcha = $_SESSION['captcha'] ?? null; if($captcha === null || mb_strtolower($captcha) !== mb_strtolower(HttpInput::Str(POST, 'captcha', false))){ - $error = new Exceptions\ValidationException(); - - $error->Add(new Exceptions\InvalidCaptchaException()); - - throw $error; + throw new Exceptions\ValidationException(new Exceptions\InvalidCaptchaException()); } $subscriber->Create(); diff --git a/www/patrons-circle/index.php b/www/patrons-circle/index.php new file mode 100644 index 00000000..e964abde --- /dev/null +++ b/www/patrons-circle/index.php @@ -0,0 +1,3 @@ + $poll->Name, 'highlight' => '', 'description' => $poll->Description]) ?> +
+
+

Name) ?>

+

Description ?>

+ IsActive()){ ?> + End !== null){ ?> +

This poll closes on End->format('F j, Y g:i A') ?>.

+ +

+ Vote now + View results +

+ + Start !== null && $poll->Start > new DateTime()){ ?> +

This poll opens on Start->format('F j, Y g:i A') ?>.

+ +

This poll closed on End->format('F j, Y g:i A') ?>.

+

View results

+ + +
+
+ diff --git a/www/patrons-circle/polls/votes/index.php b/www/patrons-circle/polls/votes/index.php new file mode 100644 index 00000000..2d5f224b --- /dev/null +++ b/www/patrons-circle/polls/votes/index.php @@ -0,0 +1,46 @@ + 'Results for the ' . $poll->Name . ' poll', 'highlight' => '', 'description' => 'The voting results for the ' . $poll->Name . ' poll.']) ?> +
+
+

Results for the Name) ?> Poll

+

Total votes: VoteCount) ?>

+ IsActive()){ ?> + End !== null){ ?> +

This poll closes on End->format('F j, Y g:i A') ?>.

+ + End !== null){ ?> +

This poll closed on End->format('F j, Y g:i A') ?>.

+ + + + PollItemsByWinner as $pollItem){ ?> + + + + + + +
Name) ?> +
+ + +
+
+
+
+ diff --git a/www/patrons-circle/polls/votes/new.php b/www/patrons-circle/polls/votes/new.php new file mode 100644 index 00000000..9669f0f7 --- /dev/null +++ b/www/patrons-circle/polls/votes/new.php @@ -0,0 +1,58 @@ + $poll->Name . ' - Vote Now', 'highlight' => '', 'description' => 'Vote in the ' . $poll->Name . ' poll']) ?> +
+
+

Vote in the Name) ?> Poll

+ $exception]) ?> +
+ +
+

Select one of these options

+
    + PollItems as $pollItem){ ?> +
  • + +
  • + +
+
+ +
+
+
+ diff --git a/www/patrons-circle/polls/votes/post.php b/www/patrons-circle/polls/votes/post.php new file mode 100644 index 00000000..c2d3b03d --- /dev/null +++ b/www/patrons-circle/polls/votes/post.php @@ -0,0 +1,51 @@ +PollItemId = HttpInput::Int(POST, 'pollitemid'); + + $vote->Create(HttpInput::Str(POST, 'email', false)); + + session_unset(); + + if($requestType == WEB){ + http_response_code(303); + header('Location: ' . $vote->PollItem->Poll->Url . '/votes/success'); + } + else{ + // Access via REST api; 201 CREATED with location + http_response_code(201); + header('Location: ' . $vote->Url); + } +} +catch(Exceptions\SeException $ex){ + // Validation failed + if($requestType == WEB){ + $_SESSION['vote'] = $vote; + $_SESSION['exception'] = $ex; + + // Access via form; 303 redirect to the form, which will emit a 400 BAD REQUEST + http_response_code(303); + header('Location: /patrons-circle/polls/' . HttpInput::Str(GET, 'pollurlname', false) . '/votes/new'); + } + else{ + // Access via REST api; 400 BAD REQUEST + http_response_code(400); + } +} diff --git a/www/patrons-circle/polls/votes/success.php b/www/patrons-circle/polls/votes/success.php new file mode 100644 index 00000000..75bdfea5 --- /dev/null +++ b/www/patrons-circle/polls/votes/success.php @@ -0,0 +1,23 @@ + 'Thank you for voting!', 'highlight' => 'newsletter', 'description' => 'Thank you for voting in a Standard Ebooks poll!']) ?> +
+
+

Thank you for voting!

+

Your vote in the Name) ?> poll has been recorded.

+

view results

+
+
+ diff --git a/www/webhooks/github.php b/www/webhooks/github.php index fe6bd321..3d870f35 100644 --- a/www/webhooks/github.php +++ b/www/webhooks/github.php @@ -17,7 +17,7 @@ $lastPushHashFlag = ''; try{ $log->Write('Received GitHub webhook.'); - if($_SERVER['REQUEST_METHOD'] != 'POST'){ + if(HttpInput::RequestMethod() != HTTP_POST){ throw new Exceptions\WebhookException('Expected HTTP POST.'); } diff --git a/www/webhooks/postmark.php b/www/webhooks/postmark.php index 5e029962..215198c3 100644 --- a/www/webhooks/postmark.php +++ b/www/webhooks/postmark.php @@ -15,7 +15,7 @@ try{ $log->Write('Received Postmark webhook.'); - if($_SERVER['REQUEST_METHOD'] != 'POST'){ + if(HttpInput::RequestMethod() != HTTP_POST){ throw new Exceptions\WebhookException('Expected HTTP POST.'); } @@ -36,7 +36,7 @@ try{ // Received when a user marks an email as spam $log->Write('Event type: spam complaint.'); - Db::Query('delete from NewsletterSubscribers where Email = ?', [$post->Email]); + Db::Query('DELETE from NewsletterSubscribers where Email = ?', [$post->Email]); } elseif($post->RecordType == 'SubscriptionChange' && $post->SuppressSending){ // Received when a user clicks Postmark's "Unsubscribe" link in a newsletter email @@ -45,7 +45,7 @@ try{ $email = $post->Recipient; // Remove the email from our newsletter list - Db::Query('delete from NewsletterSubscribers where Email = ?', [$email]); + Db::Query('DELETE from NewsletterSubscribers where Email = ?', [$email]); // Remove the suppression from Postmark, since we deleted it from our own list we will never email them again anyway $handle = curl_init(); diff --git a/www/webhooks/zoho.php b/www/webhooks/zoho.php index b6648b17..cfab0ada 100644 --- a/www/webhooks/zoho.php +++ b/www/webhooks/zoho.php @@ -1,6 +1,7 @@ Write('Received Zoho webhook.'); - if($_SERVER['REQUEST_METHOD'] != 'POST'){ + if(HttpInput::RequestMethod() != HTTP_POST){ throw new Exceptions\WebhookException('Expected HTTP POST.'); } @@ -53,7 +54,7 @@ try{ $payment->Create(); } else{ - Db::Query('insert into PendingPayments (Timestamp, ChannelId, TransactionId) values (utc_timestamp(), ?, ?);', [PAYMENT_CHANNEL_FA, $transactionId]); + Db::Query('INSERT into PendingPayments (Timestamp, ChannelId, TransactionId) values (utc_timestamp(), ?, ?);', [PAYMENT_CHANNEL_FA, $transactionId]); } $log->Write('Donation ID: ' . $transactionId);