diff --git a/README.md b/README.md index 65717de5..ed9b480a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ PHP 7+ is required. ```shell # Install Apache, PHP, PHP-FPM, and various other dependencies. -sudo apt install -y git composer php-fpm php-cli php-gd php-xml php-apcu php-mbstring php-intl apache2 apache2-utils libfcgi0ldbl task-spooler ipv6calc +sudo apt install -y git composer php-fpm php-cli php-gd php-xml php-apcu php-mbstring php-intl php-curl apache2 apache2-utils libfcgi0ldbl task-spooler ipv6calc mariadb-server # Create the site root and logs root and clone this repo into it. sudo mkdir /standardebooks.org/ diff --git a/composer.json b/composer.json index 65ab6c3f..c04f3f92 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,9 @@ "files": ["lib/Constants.php", "lib/CoreFunctions.php"] }, "require": { - "thecodingmachine/safe": "^1.0.0" + "thecodingmachine/safe": "^1.0.0", + "phpmailer/phpmailer": "6.6.0", + "ramsey/uuid": "4.2.3", + "gregwar/captcha": "1.1.9" } } diff --git a/config/apache/standardebooks.org.conf b/config/apache/standardebooks.org.conf index 6e6456aa..88056577 100644 --- a/config/apache/standardebooks.org.conf +++ b/config/apache/standardebooks.org.conf @@ -142,10 +142,9 @@ Define webroot /standardebooks.org/web # In RewriteCond, RewriteRule gets evaluated BEFORE RewriteCond, so $1 refers to the first # match in RewriteRule # Rewrite POST /some/url -> POST /some/url/post.php - RewriteCond %{REQUEST_METHOD} ^POST$ - RewriteCond %{DOCUMENT_ROOT}/$1/ -d - RewriteCond %{DOCUMENT_ROOT}/$1/post.php -f - RewriteRule ^([^\.]+)$ $1/post.php [L] + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^(post|delete|put)$/" + RewriteCond %{DOCUMENT_ROOT}/$1/%1.php -f + RewriteRule ^([^\.]+)$ $1/%1.php [L] # In case of 404, serve the 404 page specified by ErrorDocument, not the default FPM error page. # Note that we can't use `ProxyErrorOverride on` because that catches ALL 4xx and 5xx HTTP headers @@ -243,6 +242,10 @@ Define webroot /standardebooks.org/web # If we ask for /opds/all?query=xyz, rewrite that to the search page. RewriteCond %{QUERY_STRING} ^query= RewriteRule ^/opds/all.xml$ /opds/search.php [QSA] + + # Newsletter + RewriteRule ^/newsletter$ /newsletter/subscribers/new.php + RewriteRule ^/newsletter/subscribers/([^\./]+?)/(delete|confirm)$ /newsletter/subscribers/$2.php?uuid=$1 diff --git a/config/apache/standardebooks.test.conf b/config/apache/standardebooks.test.conf index 5b98c1b9..d2b987cd 100644 --- a/config/apache/standardebooks.test.conf +++ b/config/apache/standardebooks.test.conf @@ -141,10 +141,9 @@ Define webroot /standardebooks.org/web # In RewriteCond, RewriteRule gets evaluated BEFORE RewriteCond, so $1 refers to the first # match in RewriteRule # Rewrite POST /some/url -> POST /some/url/post.php - RewriteCond %{REQUEST_METHOD} ^POST$ - RewriteCond %{DOCUMENT_ROOT}/$1/ -d - RewriteCond %{DOCUMENT_ROOT}/$1/post.php -f - RewriteRule ^([^\.]+)$ $1/post.php [L] + RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^(post|delete|put)$/" + RewriteCond %{DOCUMENT_ROOT}/$1/%1.php -f + RewriteRule ^([^\.]+)$ $1/%1.php [L] # In case of 404, serve the 404 page specified by ErrorDocument, not the default FPM error page. # Note that we can't use `ProxyErrorOverride on` because that catches ALL 4xx and 5xx HTTP headers @@ -242,4 +241,8 @@ Define webroot /standardebooks.org/web # If we ask for /opds/all?query=xyz, rewrite that to the search page. RewriteCond %{QUERY_STRING} ^query= RewriteRule ^/opds/all.xml$ /opds/search.php [QSA] + + # Newsletter + RewriteRule ^/newsletter$ /newsletter/subscribers/new.php + RewriteRule ^/newsletter/subscribers/([^\./]+?)/(delete|confirm)$ /newsletter/subscribers/$2.php?uuid=$1 diff --git a/config/sql/se/NewsletterSubscribers.sql b/config/sql/se/NewsletterSubscribers.sql new file mode 100644 index 00000000..bd79caae --- /dev/null +++ b/config/sql/se/NewsletterSubscribers.sql @@ -0,0 +1,14 @@ +CREATE TABLE `NewsletterSubscribers` ( + `NewsletterSubscriberId` int(10) unsigned NOT NULL AUTO_INCREMENT, + `Email` varchar(80) NOT NULL, + `Uuid` char(36) NOT NULL, + `FirstName` varchar(80) DEFAULT NULL, + `LastName` varchar(80) DEFAULT NULL, + `IsConfirmed` tinyint(1) unsigned NOT NULL DEFAULT 0, + `IsSubscribedToNewsletter` tinyint(1) unsigned NOT NULL DEFAULT 1, + `IsSubscribedToSummary` tinyint(1) unsigned NOT NULL DEFAULT 1, + `Timestamp` datetime NOT NULL, + PRIMARY KEY (`NewsletterSubscriberId`), + UNIQUE KEY `Uuid_UNIQUE` (`Uuid`), + UNIQUE KEY `Email_UNIQUE` (`Email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/lib/Constants.php b/lib/Constants.php index 166b08e0..a6d8d4a3 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -1,12 +1,29 @@ strtotime('November 15, ' . gmdate( define('DONATION_ALERT_ON', DONATION_HOLIDAY_ALERT_ON || rand(1, 4) == 2); define('DONATION_DRIVE_ON', false); -// No trailing slash on any of the below constants. -const SITE_URL = 'https://standardebooks.org'; -const SITE_ROOT = '/standardebooks.org'; -const WEB_ROOT = SITE_ROOT . '/web/www'; -const REPOS_PATH = SITE_ROOT . '/ebooks'; -const TEMPLATES_PATH = SITE_ROOT . '/web/templates'; -const MANUAL_PATH = WEB_ROOT . '/manual'; -const EBOOKS_DIST_PATH = WEB_ROOT . '/ebooks/'; - const GITHUB_SECRET_FILE_PATH = SITE_ROOT . '/config/secrets/se-vcs-bot@github.com'; // Set in the GitHub organization global webhook settings. const GITHUB_WEBHOOK_LOG_FILE_PATH = '/var/log/local/webhooks-github.log'; // Must be writable by `www-data` Unix user. +const GITHUB_IGNORED_REPOS = ['tools', 'manual', 'web']; // If we get GitHub push requests featuring these repos, silently ignore instead of returning an error. -// If we get GitHub push requests featuring these repos, silently ignore instead of returning an error. -const GITHUB_IGNORED_REPOS = ['tools', 'manual', 'web']; +const POSTMARK_WEBHOOK_LOG_FILE_PATH = '/var/log/local/webhooks-postmark.log'; // Must be writable by `www-data` Unix user. diff --git a/lib/DbConnection.php b/lib/DbConnection.php index 583b28fb..06a20f3d 100644 --- a/lib/DbConnection.php +++ b/lib/DbConnection.php @@ -10,7 +10,10 @@ class DbConnection{ public function __construct(?string $defaultDatabase = null, string $host = 'localhost', ?string $user = null, string$password = '', bool $forceUtf8 = true, bool $require = true){ if($user === null){ // Get the user running the script for local socket login - $user = posix_getpwuid(posix_geteuid())['name']; + $user = posix_getpwuid(posix_geteuid()); + if($user){ + $user = $user['name']; + } } $connectionString = 'mysql:'; @@ -125,9 +128,9 @@ class DbConnection{ usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds } - elseif(stripos($ex->getMessage(), '1064 offset out of bounds') !== false){ - $done = true; - // We reach here if Sphinx tries to get a record past its page limit. Just silently do nothing. + elseif($ex->getCode() == '23000'){ + // Duplicate key, bubble this up without logging it so the business logic can handle it + throw($ex); } else{ $done = true; @@ -139,16 +142,12 @@ class DbConnection{ Logger::WriteErrorLogEntry($ex->getMessage()); Logger::WriteErrorLogEntry($preparedSql); Logger::WriteErrorLogEntry(vds($params)); + throw($ex); } } } } - // If only one rowset is returned, change the result object - if(sizeof($result) == 1){ - $result = $result[0]; - } - return $result; } @@ -168,7 +167,7 @@ class DbConnection{ for($i = 0; $i < $columnCount; $i++){ $metadata[$i] = $handle->getColumnMeta($i); - if(preg_match('/^(Is|Has|Can)[A-Z]/u', $metadata[$i]['name']) === 1){ + if($metadata[$i] && preg_match('/^(Is|Has|Can)[A-Z]/u', $metadata[$i]['name']) === 1){ // MySQL doesn't have a native boolean type, so fake it here if the column // name starts with Is, Has, or Can and is followed by an uppercase letter $metadata[$i]['native_type'] = 'BOOL'; diff --git a/lib/Ebook.php b/lib/Ebook.php index dd0fb2e9..8f72a9aa 100644 --- a/lib/Ebook.php +++ b/lib/Ebook.php @@ -69,15 +69,15 @@ class Ebook{ } if(!is_dir($wwwFilesystemPath)){ - throw new InvalidEbookException('Invalid www filesystem path: ' . $wwwFilesystemPath); + throw new Exceptions\InvalidEbookException('Invalid www filesystem path: ' . $wwwFilesystemPath); } if(!is_dir($this->RepoFilesystemPath)){ - throw new InvalidEbookException('Invalid repo filesystem path: ' . $this->RepoFilesystemPath); + throw new Exceptions\InvalidEbookException('Invalid repo filesystem path: ' . $this->RepoFilesystemPath); } if(!is_file($wwwFilesystemPath . '/content.opf')){ - throw new InvalidEbookException('Invalid content.opf file: ' . $wwwFilesystemPath . '/content.opf'); + throw new Exceptions\InvalidEbookException('Invalid content.opf file: ' . $wwwFilesystemPath . '/content.opf'); } $this->WwwFilesystemPath = $wwwFilesystemPath; @@ -88,7 +88,7 @@ class Ebook{ // Get the SE identifier. preg_match('|]*?>(.+?)|ius', $rawMetadata, $matches); if(sizeof($matches) != 2){ - throw new EbookParsingException('Invalid element.'); + throw new Exceptions\EbookParsingException('Invalid element.'); } $this->Identifier = (string)$matches[1]; @@ -175,7 +175,7 @@ class Ebook{ $this->Title = $this->NullIfEmpty($xml->xpath('/package/metadata/dc:title')); if($this->Title === null){ - throw new EbookParsingException('Invalid element.'); + throw new Exceptions\EbookParsingException('Invalid element.'); } $this->Title = str_replace('\'', '’', $this->Title); @@ -256,7 +256,7 @@ class Ebook{ } if(sizeof($this->Authors) == 0){ - throw new EbookParsingException('Invalid element.'); + throw new Exceptions\EbookParsingException('Invalid element.'); } $this->AuthorsUrl = preg_replace('|url:https://standardebooks.org/ebooks/([^/]+)/.*|ius', '/ebooks/\1', $this->Identifier); diff --git a/lib/EbookParsingException.php b/lib/EbookParsingException.php deleted file mode 100644 index 2a7d0aec..00000000 --- a/lib/EbookParsingException.php +++ /dev/null @@ -1,3 +0,0 @@ -ReplyTo == ''){ + $this->ReplyTo = $this->From; + } + + if($this->To === null || $this->To == ''){ + return false; + } + + $phpMailer = new PHPMailer(true); + + try{ + $phpMailer->SetFrom($this->From, $this->FromName); + $phpMailer->AddReplyTo($this->ReplyTo); + $phpMailer->AddAddress($this->To); + $phpMailer->Subject = $this->Subject; + $phpMailer->CharSet = 'UTF-8'; + if($this->TextBody !== null && $this->TextBody != ''){ + $phpMailer->IsHTML(true); + $phpMailer->Body = $this->Body; + $phpMailer->AltBody = $this->TextBody; + } + else{ + $phpMailer->MsgHTML($this->Body); + } + + foreach($this->Attachments as $attachment){ + if(is_array($attachment)){ + $phpMailer->addStringAttachment($attachment['contents'], $attachment['filename']); + } + } + + $phpMailer->IsSMTP(); + $phpMailer->SMTPAuth = true; + $phpMailer->SMTPSecure = 'tls'; + $phpMailer->Port = 587; + $phpMailer->Host = EMAIL_SMTP_HOST; + $phpMailer->Username = EMAIL_SMTP_USERNAME; + $phpMailer->Password = EMAIL_SMTP_PASSWORD; + + if($this->PostmarkStream !== null){ + $phpMailer->addCustomHeader('X-PM-Message-Stream', $this->PostmarkStream); + } + + if(SITE_STATUS == SITE_STATUS_DEV){ + Logger::WriteErrorLogEntry('Sending mail to ' . $this->To . ' from ' . $this->From); + Logger::WriteErrorLogEntry('Subject: ' . $this->Subject); + Logger::WriteErrorLogEntry($this->Body); + Logger::WriteErrorLogEntry($this->TextBody); + } + else{ + $phpMailer->Send(); + } + } + catch(Exception $ex){ + if(SITE_STATUS != SITE_STATUS_DEV){ + Logger::WriteErrorLogEntry('Failed sending email to ' . $this->To . ' Exception: ' . $ex->errorMessage() . "\n" . ' Subject: ' . $this->Subject . "\nBody:\n" . $this->Body); + } + + return false; + } + + return true; + } + + public function __construct(bool $isNoReplyEmail = false){ + if($isNoReplyEmail){ + $this->From = NO_REPLY_EMAIL_ADDRESS; + $this->FromName = 'Standard Ebooks'; + $this->ReplyTo = NO_REPLY_EMAIL_ADDRESS; + } + } +} diff --git a/lib/Exceptions/EbookParsingException.php b/lib/Exceptions/EbookParsingException.php new file mode 100644 index 00000000..e4ed164a --- /dev/null +++ b/lib/Exceptions/EbookParsingException.php @@ -0,0 +1,5 @@ +Exceptions as $exception){ + $output .= $exception->getMessage() . '; '; + } + + return rtrim($output, '; '); + } + + public function Add(\Exception $exception, bool $isFatal = false): void{ + if(is_a($exception, static::class)){ + foreach($exception->Exceptions as $childException){ + $this->Add($childException); + } + } + else{ + $this->Exceptions[] = $exception; + } + + if($isFatal){ + $this->IsFatal = true; + } + + $this->HasExceptions = true; + } + + public function Serialize(): string{ + $val = ''; + foreach($this->Exceptions as $childException){ + $val .= $childException->getCode() . ','; + } + + $val = rtrim($val, ','); + + return $val; + } + + public function Has(string $exception): bool{ + foreach($this->Exceptions as $childException){ + if(is_a($childException, $exception)){ + return true; + } + } + + return false; + } + + public function Clear(): void{ + unset($this->Exceptions); + $this->Exceptions = []; + $this->HasExceptions = false; + } +} diff --git a/lib/WebhookException.php b/lib/Exceptions/WebhookException.php similarity index 70% rename from lib/WebhookException.php rename to lib/Exceptions/WebhookException.php index 48189862..ee183597 100644 --- a/lib/WebhookException.php +++ b/lib/Exceptions/WebhookException.php @@ -1,5 +1,7 @@ Url === null){ + $this->Url = SITE_URL . '/newsletter/subscribers/' . $this->Uuid; + } + + return $this->Url; + } + + public function Create(): void{ + $this->Validate(); + + $uuid = Uuid::uuid4(); + $this->Uuid = $uuid->toString(); + + 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]); + } + catch(PDOException $ex){ + if($ex->getCode() == '23000'){ + // Duplicate unique key; email already in use + throw new Exceptions\NewsletterSubscriberExistsException(); + } + } + + $this->NewsletterSubscriberId = Db::GetLastInsertedId(); + + // Send the double opt-in confirmation email + $em = new Email(true); + $em->PostmarkStream = EMAIL_POSTMARK_STREAM_BROADCAST; + $em->To = $this->Email; + $em->Subject = 'Action required: confirm your newsletter subscription'; + $em->Body = Template::EmailNewsletterConfirmation(['subscriber' => $this]); + $em->TextBody = Template::EmailNewsletterConfirmationText(['subscriber' => $this]); + $em->Send(); + } + + public function Confirm(): void{ + Db::Query('update NewsletterSubscribers set IsConfirmed = true where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]); + } + + public function Delete(): void{ + Db::Query('delete from NewsletterSubscribers where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]); + } + + public function Validate(): void{ + $error = new Exceptions\ValidationException(); + + if($this->Email == '' || !filter_var($this->Email, FILTER_VALIDATE_EMAIL)){ + $error->Add(new Exceptions\InvalidEmailException()); + } + + if(!$this->IsSubscribedToSummary && !$this->IsSubscribedToNewsletter){ + $error->Add(new Exceptions\NewsletterRequiredException()); + } + + if($error->HasExceptions){ + throw $error; + } + } + + public static function Get(string $uuid): NewsletterSubscriber{ + if($uuid == ''){ + throw new Exceptions\InvalidNewsletterSubscriberException(); + } + + $subscribers = Db::Query('select * from NewsletterSubscribers where Uuid = ?;', [$uuid], 'NewsletterSubscriber'); + + if(sizeof($subscribers) == 0){ + throw new Exceptions\InvalidNewsletterSubscriberException(); + } + + return $subscribers[0]; + } +} diff --git a/lib/NoopException.php b/lib/NoopException.php deleted file mode 100644 index b439b55d..00000000 --- a/lib/NoopException.php +++ /dev/null @@ -1,3 +0,0 @@ -registerXPathNamespace('schema', 'http://schema.org/'); $entries = $xml->xpath('/feed/entry'); - if($entries === false){ + if(!$entries){ $entries = []; } @@ -37,7 +37,7 @@ class OpdsFeed{ // while updating it at the same time. $elements = $xml->xpath('/feed/entry/updated'); - if($elements === false){ + if(!$elements){ $elements = []; } @@ -71,7 +71,7 @@ class OpdsFeed{ $xml = new SimpleXMLElement(str_replace('xmlns=', 'ns=', file_get_contents($parentFilepath))); $feedEntries = $xml->xpath('/feed/entry[id="' . $this->Id . '"]/updated'); - if($feedEntries === false){ + if(!$feedEntries){ $feedEntries = []; } diff --git a/lib/OpdsNavigationFeed.php b/lib/OpdsNavigationFeed.php index 56f60b88..afe3af32 100644 --- a/lib/OpdsNavigationFeed.php +++ b/lib/OpdsNavigationFeed.php @@ -1,6 +1,7 @@ = 7'); +?> diff --git a/scripts/generate-rss.php b/scripts/generate-rss.php index 66a6755b..1da1daa9 100644 --- a/scripts/generate-rss.php +++ b/scripts/generate-rss.php @@ -3,6 +3,7 @@ require_once('/standardebooks.org/web/lib/Core.php'); use function Safe\file_get_contents; use function Safe\getopt; +use function Safe\gmdate; use function Safe\krsort; use function Safe\preg_replace; use function Safe\strtotime; diff --git a/templates/EmailFooter.php b/templates/EmailFooter.php new file mode 100644 index 00000000..ad1b4d25 --- /dev/null +++ b/templates/EmailFooter.php @@ -0,0 +1,12 @@ + + + diff --git a/templates/EmailHeader.php b/templates/EmailHeader.php new file mode 100644 index 00000000..0118bbce --- /dev/null +++ b/templates/EmailHeader.php @@ -0,0 +1,148 @@ + + + + + + + + + + +

‌ 

diff --git a/templates/EmailNewsletterConfirmation.php b/templates/EmailNewsletterConfirmation.php new file mode 100644 index 00000000..33309085 --- /dev/null +++ b/templates/EmailNewsletterConfirmation.php @@ -0,0 +1,9 @@ + +

Confirm your newsletter subscription

+

Thank you for subscribing to the Standard Ebooks newsletter!

+

Please use the button below to confirm your newsletter subscription. You won’t receive our newsletters until you confirm your subscription.

+

+ Yes, subscribe me to the newsletter +

+

If you didn’t subscribe to our newsletter, or you’re not sure why you received this email, you can safely delete it and you won’t receive any more email from us.

+ diff --git a/templates/EmailNewsletterConfirmationText.php b/templates/EmailNewsletterConfirmationText.php new file mode 100644 index 00000000..1aa4c28c --- /dev/null +++ b/templates/EmailNewsletterConfirmationText.php @@ -0,0 +1,9 @@ +# Confirm your newsletter subscription + +Thank you for subscribing to the Standard Ebooks newsletter! + +Please use the link below to confirm your newsletter subscription. You won’t receive our newsletters until you confirm your subscription. + +<Url ?>/confirm> + +If you didn’t subscribe to our newsletter, or you’re not sure why you received this email, you can safely delete it and you won’t receive any more email from us. diff --git a/templates/Error.php b/templates/Error.php new file mode 100644 index 00000000..088a214d --- /dev/null +++ b/templates/Error.php @@ -0,0 +1,22 @@ +Exceptions; +} +else{ + $exceptions[] = $exception; +} +?> +
    + +
  • +

    getMessage(); if($message == ''){ $message = 'An error occurred.'; } ?>

    +
  • + +
diff --git a/www/css/core.css b/www/css/core.css index 6151ed55..01b627bd 100644 --- a/www/css/core.css +++ b/www/css/core.css @@ -625,6 +625,19 @@ input[type="checkbox"]:focus{ outline: none; } +ul.error{ + border: 2px solid #c33b3b; + border-radius: .25rem; + background: #e14646; + padding: 1rem; + color: #fff; + text-shadow: 1px 1px 0px rgba(0, 0, 0, .75); +} + +ul.error li:only-child{ + list-style: none; +} + .ebooks nav li.highlighted a:focus, a.button:focus, input[type="email"]:focus, @@ -1471,6 +1484,10 @@ input::placeholder{ color: rgba(0, 0, 0, .75); } +label.automation-test{ + display: none; +} + label.email, label.search{ display: block; @@ -1498,7 +1515,8 @@ input[type="search"]{ box-shadow: 1px 1px 0 rgba(255, 255, 255, .5) inset; } -label[class]:not(.text) input{ +label.email input, +label.search input{ padding-left: 2.5rem; } @@ -1987,7 +2005,7 @@ article.ebook section aside.donation p::after{ left: -5000px; } -form[action*="list-manage.com"]{ +form[action="/newsletter/subscribers"]{ display: grid; grid-gap: 1rem; grid-template-columns: 1fr 1fr; @@ -1995,21 +2013,32 @@ form[action*="list-manage.com"]{ margin-bottom: 0; } -form[action*="list-manage.com"] label.email{ +form[action="/newsletter/subscribers"] label.email, +form[action="/newsletter/subscribers"] label.captcha{ grid-column: 1 / span 2; } -form[action*="list-manage.com"] ul{ +form[action="/newsletter/subscribers"] label.captcha div{ + display: grid; + grid-gap: 1rem; + grid-template-columns: 1fr 1fr; +} + +form[action="/newsletter/subscribers"] label.captcha div input{ + align-self: center; +} + +form[action="/newsletter/subscribers"] ul{ list-style: none; } -form[action*="list-manage.com"] button{ +form[action="/newsletter/subscribers"] button{ grid-column: 2; justify-self: end; margin-left: 0; } -form[action*="list-manage.com"] fieldset{ +form[action="/newsletter/subscribers"] fieldset{ margin-top: 1rem; grid-column: 1 / span 2; } @@ -2761,8 +2790,9 @@ aside button.close:active{ margin-top: 0; } - form[action*="list-manage.com"] label.text, - form[action*="list-manage.com"] fieldset{ + form[action="/newsletter/subscribers"] label.text, + form[action="/newsletter/subscribers"] label.captcha, + form[action="/newsletter/subscribers"] fieldset{ grid-column: 1 / span 2; } diff --git a/www/ebooks/author.php b/www/ebooks/author.php index 38951b4e..84e44deb 100644 --- a/www/ebooks/author.php +++ b/www/ebooks/author.php @@ -7,16 +7,16 @@ try{ if($urlPath == '' || mb_stripos($wwwFilesystemPath, EBOOKS_DIST_PATH) !== 0 || !is_dir($wwwFilesystemPath)){ // Ensure the path exists and that the root is in our www directory - throw new InvalidAuthorException(); + throw new Exceptions\InvalidAuthorException(); } $ebooks = Library::GetEbooksByAuthor($wwwFilesystemPath); if(sizeof($ebooks) == 0){ - throw new InvalidAuthorException(); + throw new Exceptions\InvalidAuthorException(); } } -catch(InvalidAuthorException $ex){ +catch(Exceptions\InvalidAuthorException $ex){ http_response_code(404); include(WEB_ROOT . '/404.php'); exit(); diff --git a/www/ebooks/ebook.php b/www/ebooks/ebook.php index fd6a5307..00ffb73a 100644 --- a/www/ebooks/ebook.php +++ b/www/ebooks/ebook.php @@ -14,7 +14,7 @@ try{ if($urlPath == '' || mb_stripos($wwwFilesystemPath, EBOOKS_DIST_PATH) !== 0){ // Ensure the path exists and that the root is in our www directory - throw new InvalidEbookException(); + throw new Exceptions\InvalidEbookException(); } // Were we passed the author and a work but not the translator? @@ -28,7 +28,7 @@ try{ // This iterator will do a deep scan on the directory. When we hit another directory, the filename will be "." and the path will contain the directory path. // We want to find where the "src" directory is, and the directory directly below that will be the final web URL we're looking for. if($file->getFilename() == '.' && preg_match('|/src$|ius', $file->getPath())){ - throw new SeeOtherEbookException(preg_replace(['|' . WEB_ROOT . '|ius', '|/src$|ius'], '', $file->getPath())); + throw new Exceptions\SeeOtherEbookException(preg_replace(['|' . WEB_ROOT . '|ius', '|/src$|ius'], '', $file->getPath())); } } } @@ -60,12 +60,12 @@ try{ $i++; } } -catch(SeeOtherEbookException $ex){ +catch(Exceptions\SeeOtherEbookException $ex){ http_response_code(301); header('Location: ' . $ex->Url); exit(); } -catch(InvalidEbookException $ex){ +catch(Exceptions\InvalidEbookException $ex){ http_response_code(404); include(WEB_ROOT . '/404.php'); exit(); diff --git a/www/ebooks/index.php b/www/ebooks/index.php index 21076398..eeeed234 100644 --- a/www/ebooks/index.php +++ b/www/ebooks/index.php @@ -83,7 +83,7 @@ try{ $pageHeader = 'Free ebooks in the ' . Formatter::ToPlainText($collectionName) . ' ' . $collectionType; } else{ - throw new InvalidCollectionException(); + throw new Exceptions\InvalidCollectionException(); } } else{ @@ -127,7 +127,7 @@ try{ $queryString = preg_replace('/^&/ius', '', $queryString); } -catch(InvalidCollectionException $ex){ +catch(Exceptions\InvalidCollectionException $ex){ http_response_code(404); include(WEB_ROOT . '/404.php'); exit(); diff --git a/www/images/captcha.php b/www/images/captcha.php new file mode 100644 index 00000000..3534bc54 --- /dev/null +++ b/www/images/captcha.php @@ -0,0 +1,17 @@ +build(CAPTCHA_IMAGE_WIDTH, CAPTCHA_IMAGE_HEIGHT); + +$_SESSION['captcha'] = $builder->getPhrase(); + +$builder->output(); diff --git a/www/newsletter/index.php b/www/newsletter/index.php deleted file mode 100644 index 34477853..00000000 --- a/www/newsletter/index.php +++ /dev/null @@ -1,43 +0,0 @@ - 'Subscribe to the Standard Ebooks newsletter', 'highlight' => 'newsletter', 'description' => 'Subscribe to the Standard Ebooks newsletter to receive occasional updates about the project.']) ?> -
-
-
-

Subscribe to the Newsletter

-

to receive missives from the vanguard of digital literature

-
- - - - An old man in Renaissance-era costume reading a sheet of paper. - -

Subscribe to receive news, updates, and more from Standard Ebooks. Your information will never be shared, and you can unsubscribe at any time.

-
- - - - - -
-

What kind of email would you like to receive?

-
    -
  • - -
  • -
  • - -
  • -
-
- -
-
-
- diff --git a/www/newsletter/subscribers/confirm.php b/www/newsletter/subscribers/confirm.php new file mode 100644 index 00000000..ae4e6c8b --- /dev/null +++ b/www/newsletter/subscribers/confirm.php @@ -0,0 +1,21 @@ +Confirm(); +} +catch(Exceptions\InvalidNewsletterSubscriberException $ex){ + http_response_code(404); + include(WEB_ROOT . '/404.php'); + exit(); +} +?> 'Your subscription to the Standard Ebooks newsletter has been confirmed', 'highlight' => 'newsletter', 'description' => 'Your subscription to the Standard Ebooks newsletter has been confirmed.']) ?> +
+
+

Your subscription is confirmed!

+

Thank you! You’ll now receive Standard Ebooks email newsletters.

+

To unsubscribe, simply follow the link at the bottom of any of our newsletters, or click here.

+
+
+ diff --git a/www/newsletter/subscribers/delete.php b/www/newsletter/subscribers/delete.php new file mode 100644 index 00000000..544a2cbd --- /dev/null +++ b/www/newsletter/subscribers/delete.php @@ -0,0 +1,41 @@ +Delete(); + + if($requestType == REST){ + exit(); + } +} +catch(Exceptions\InvalidRequestException $ex){ + http_response_code(405); + exit(); +} +catch(Exceptions\InvalidNewsletterSubscriberException $ex){ + http_response_code(404); + if($requestType == WEB){ + include(WEB_ROOT . '/404.php'); + } + exit(); +} + +?> 'You’ve unsubscribed from the Standard Ebooks newsletter', 'highlight' => 'newsletter', 'description' => 'You’ve unsubscribed from the Standard Ebooks newsletter.']) ?> +
+
+

You’ve been unsubscribed

+

You’ll no longer receive Standard Ebooks email newsletters. Sorry to see you go!

+
+
+ + diff --git a/www/newsletter/subscribers/new.php b/www/newsletter/subscribers/new.php new file mode 100644 index 00000000..561a91d2 --- /dev/null +++ b/www/newsletter/subscribers/new.php @@ -0,0 +1,67 @@ + 'Subscribe to the Standard Ebooks newsletter', 'highlight' => 'newsletter', 'description' => 'Subscribe to the Standard Ebooks newsletter to receive occasional updates about the project.']) ?> +
+
+
+

Subscribe to the Newsletter

+

to receive missives from the vanguard of digital literature

+
+ + + + An old man in Renaissance-era costume reading a sheet of paper. + +

Subscribe to receive news, updates, and more from Standard Ebooks. Your information will never be shared, and you can unsubscribe at any time.

+ + $exception]) ?> + +
+ + + + + +
+

What kind of email would you like to receive?

+
    +
  • + +
  • +
  • + +
  • +
+
+ +
+
+
+ diff --git a/www/newsletter/subscribers/post.php b/www/newsletter/subscribers/post.php new file mode 100644 index 00000000..befd4f99 --- /dev/null +++ b/www/newsletter/subscribers/post.php @@ -0,0 +1,77 @@ +Url); + } + exit(); +} + +try{ + $subscriber->FirstName = HttpInput::Str(POST, 'firstname', false); + $subscriber->LastName = HttpInput::Str(POST, 'lastname', false); + $subscriber->Email = HttpInput::Str(POST, 'email', false); + $subscriber->IsSubscribedToNewsletter = HttpInput::Bool(POST, 'newsletter'); + $subscriber->IsSubscribedToSummary = HttpInput::Bool(POST, 'monthlysummary'); + + $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; + } + + $subscriber->Create(); + + session_unset(); + + if($requestType == WEB){ + http_response_code(303); + header('Location: /newsletter/subscribers/success'); + } + else{ + // Access via REST api; 201 CREATED with location + http_response_code(201); + header('Location: ' . $subscriber->Url); + } +} +catch(Exceptions\SeException $ex){ + // Validation failed + if($requestType == WEB){ + $_SESSION['subscriber'] = $subscriber; + $_SESSION['exception'] = $ex; + + // Access via form; 303 redirect to the form, which will emit a 400 BAD REQUEST + http_response_code(303); + header('Location: /newsletter/subscribers/new'); + } + else{ + // Access via REST api; 400 BAD REQUEST + http_response_code(400); + } +} diff --git a/www/newsletter/subscribers/success.php b/www/newsletter/subscribers/success.php new file mode 100644 index 00000000..6e173300 --- /dev/null +++ b/www/newsletter/subscribers/success.php @@ -0,0 +1,12 @@ + 'Subscribe to the Standard Ebooks newsletter', 'highlight' => 'newsletter', 'description' => 'Subscribe to the Standard Ebooks newsletter to receive occasional updates about the project.']) ?> +
+
+

Almost done!

+

Please check your email inbox for a confirmation email containing a link to finalize your subscription to our newsletter.

+

Your subscription won’t be activated until you click that link—this helps us prevent spam. Thank you!

+
+
+ diff --git a/www/webhooks/github.php b/www/webhooks/github.php index 92aec107..88c8b40b 100644 --- a/www/webhooks/github.php +++ b/www/webhooks/github.php @@ -19,7 +19,7 @@ try{ Logger::WriteGithubWebhookLogEntry($requestId, 'Received GitHub webhook.'); if($_SERVER['REQUEST_METHOD'] != 'POST'){ - throw new WebhookException('Expected HTTP POST.'); + throw new Exceptions\WebhookException('Expected HTTP POST.'); } $post = file_get_contents('php://input') ?: ''; @@ -30,12 +30,12 @@ try{ $hash = $splitHash[1]; if(!hash_equals($hash, hash_hmac($hashAlgorithm, $post, preg_replace("/[\r\n]/ius", '', file_get_contents(GITHUB_SECRET_FILE_PATH) ?: '') ?? ''))){ - throw new WebhookException('Invalid GitHub webhook secret.', $post); + throw new Exceptions\InvalidCredentialsException(); } // Sanity check before we continue. if(!array_key_exists('HTTP_X_GITHUB_EVENT', $_SERVER)){ - throw new WebhookException('Couldn\'t understand HTTP request.', $post); + throw new Exceptions\WebhookException('Couldn\'t understand HTTP request.', $post); } $data = json_decode($post, true); @@ -45,20 +45,20 @@ try{ case 'ping': // Silence on success. Logger::WriteGithubWebhookLogEntry($requestId, 'Event type: ping.'); - throw new NoopException(); + throw new Exceptions\NoopException(); case 'push': Logger::WriteGithubWebhookLogEntry($requestId, 'Event type: push.'); // Get the ebook ID. PHP doesn't throw exceptions on invalid array indexes, so check that first. if(!array_key_exists('repository', $data) || !array_key_exists('name', $data['repository'])){ - throw new WebhookException('Couldn\'t understand HTTP POST data.', $post); + throw new Exceptions\WebhookException('Couldn\'t understand HTTP POST data.', $post); } $repoName = trim($data['repository']['name'], '/'); if(in_array($repoName, GITHUB_IGNORED_REPOS)){ Logger::WriteGithubWebhookLogEntry($requestId, 'Repo is in ignore list, no action taken.'); - throw new NoopException(); + throw new Exceptions\NoopException(); } // Get the filesystem path for the ebook. @@ -73,7 +73,7 @@ try{ } if(!file_exists($dir . '/HEAD')){ - throw new WebhookException('Couldn\'t find repo "' . $repoName . '" in filesystem at "' . $dir . '".', $post); + throw new Exceptions\WebhookException('Couldn\'t find repo "' . $repoName . '" in filesystem at "' . $dir . '".', $post); } } @@ -84,13 +84,13 @@ try{ if($lastCommitSha1 == ''){ Logger::WriteGithubWebhookLogEntry($requestId, 'Error getting last local commit. Output: ' . $lastCommitSha1); - throw new WebhookException('Couldn\'t process ebook.', $post); + throw new Exceptions\WebhookException('Couldn\'t process ebook.', $post); } else{ if($data['after'] == $lastCommitSha1){ // This commit is already in our local repo, so silent success Logger::WriteGithubWebhookLogEntry($requestId, 'Local repo already in sync, no action taken.'); - throw new NoopException(); + throw new Exceptions\NoopException(); } } @@ -109,7 +109,7 @@ try{ exec('sudo --set-home --user se-vcs-bot ' . SITE_ROOT . '/scripts/pull-from-github ' . escapeshellarg($dir) . ' 2>&1', $output, $returnCode); if($returnCode != 0){ Logger::WriteGithubWebhookLogEntry($requestId, 'Error pulling from GitHub. Output: ' . implode("\n", $output)); - throw new WebhookException('Couldn\'t process ebook.', $post); + throw new Exceptions\WebhookException('Couldn\'t process ebook.', $post); } else{ Logger::WriteGithubWebhookLogEntry($requestId, '`git pull` from GitHub complete.'); @@ -120,7 +120,7 @@ try{ exec('sudo --set-home --user se-vcs-bot tsp ' . SITE_ROOT . '/web/scripts/deploy-ebook-to-www' . $lastPushHashFlag . ' ' . escapeshellarg($dir) . ' 2>&1', $output, $returnCode); if($returnCode != 0){ Logger::WriteGithubWebhookLogEntry($requestId, 'Error queueing ebook for deployment to web. Output: ' . implode("\n", $output)); - throw new WebhookException('Couldn\'t process ebook.', $post); + throw new Exceptions\WebhookException('Couldn\'t process ebook.', $post); } else{ Logger::WriteGithubWebhookLogEntry($requestId, 'Queue for deployment to web complete.'); @@ -128,13 +128,17 @@ try{ break; default: - throw new WebhookException('Unrecognized GitHub webhook event.', $post); + throw new Exceptions\WebhookException('Unrecognized GitHub webhook event.', $post); } // "Success, no content" http_response_code(204); } -catch(WebhookException $ex){ +catch(Exceptions\InvalidCredentialsException $ex){ + // "Forbidden" + http_response_code(403); +} +catch(Exceptions\WebhookException $ex){ // Uh oh, something went wrong! // Log detailed error and debugging information locally. Logger::WriteGithubWebhookLogEntry($requestId, 'Webhook failed! Error: ' . $ex->getMessage()); @@ -146,7 +150,7 @@ catch(WebhookException $ex){ // "Client error" http_response_code(400); } -catch(NoopException $ex){ +catch(Exceptions\NoopException $ex){ // We arrive here because a special case required us to take no action for the request, but execution also had to be interrupted. // For example, we received a request for a known repo for which we must ignore requests. diff --git a/www/webhooks/postmark.php b/www/webhooks/postmark.php new file mode 100644 index 00000000..8a8ddb8c --- /dev/null +++ b/www/webhooks/postmark.php @@ -0,0 +1,87 @@ +RecordType == 'SpamComplaint'){ + // Received when a user marks an email as spam + Logger::WritePostmarkWebhookLogEntry($requestId, 'Event type: spam complaint.'); + + 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 + Logger::WritePostmarkWebhookLogEntry($requestId, 'Event type: unsubscribe.'); + + $email = $post->Recipient; + + // Remove the email from our newsletter list + 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(); + curl_setopt($handle, CURLOPT_URL, 'https://api.postmarkapp.com/message-streams/' . $post->MessageStream . '/suppressions/delete'); + curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($handle, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', 'X-Postmark-Server-Token: ' . EMAIL_SMTP_USERNAME]); + curl_setopt($handle, CURLOPT_CUSTOMREQUEST, "POST"); + curl_setopt($handle, CURLOPT_POSTFIELDS, '{"Suppressions": [{"EmailAddress": "' . $email . '"}]}'); + curl_exec($handle); + curl_close($handle); + } + elseif($post->RecordType == 'SubscriptionChange' && $post->SuppressionReason === null){ + Logger::WritePostmarkWebhookLogEntry($requestId, 'Event type: suppression deletion.'); + } + else{ + Logger::WritePostmarkWebhookLogEntry($requestId, 'Unrecognized event: ' . $post->RecordType); + } + + Logger::WritePostmarkWebhookLogEntry($requestId, 'Event processed.'); + + // "Success, no content" + http_response_code(204); +} +catch(Exceptions\InvalidCredentialsException $ex){ + // "Forbidden" + Logger::WritePostmarkWebhookLogEntry($requestId, 'Invalid key: ' . ($_SERVER['HTTP_X_SE_KEY'] ?? '')); + http_response_code(403); +} +catch(Exceptions\WebhookException $ex){ + // Uh oh, something went wrong! + // Log detailed error and debugging information locally. + Logger::WritePostmarkWebhookLogEntry($requestId, 'Webhook failed! Error: ' . $ex->getMessage()); + Logger::WritePostmarkWebhookLogEntry($requestId, 'Webhook POST data: ' . $ex->PostData); + + // Print less details to the client. + print($ex->getMessage()); + + // "Client error" + http_response_code(400); +}