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 @@
// Auto-included by Composer in composer.json to satisfy PHPStan
use function Safe\define;
+use function Safe\file_get_contents;
+use function Safe\gmdate;
use function Safe\strtotime;
const SITE_STATUS_LIVE = 'live';
const SITE_STATUS_DEV = 'dev';
define('SITE_STATUS', getenv('SITE_STATUS') ?: SITE_STATUS_DEV); // Set in the PHP FPM pool configuration. Have to use define() and not const so we can use a function.
+// No trailing slash on any of the below constants.
+if(SITE_STATUS == SITE_STATUS_LIVE){
+ define('SITE_URL', 'https://standardebooks.org');
+}
+else{
+ define('SITE_URL', 'https://standardebooks.test');
+}
+
+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 DATABASE_DEFAULT_DATABASE = 'se';
const DATABASE_DEFAULT_HOST = 'localhost';
@@ -16,9 +33,21 @@ const SORT_AUTHOR_ALPHA = 'author-alpha';
const SORT_READING_EASE = 'reading-ease';
const SORT_LENGTH = 'length';
-const GET = 0;
-const POST = 1;
-const COOKIE = 2;
+const CAPTCHA_IMAGE_HEIGHT = 72;
+const CAPTCHA_IMAGE_WIDTH = 230;
+
+const NO_REPLY_EMAIL_ADDRESS = 'admin@standardebooks.org';
+const EMAIL_SMTP_HOST = 'smtp-broadcasts.postmarkapp.com';
+define('EMAIL_SMTP_USERNAME', trim(file_get_contents(SITE_ROOT . '/config/secrets/postmarkapp.com')) ?: '');
+const EMAIL_SMTP_PASSWORD = EMAIL_SMTP_USERNAME;
+const EMAIL_POSTMARK_STREAM_BROADCAST = 'the-standard-ebooks-newsletter';
+
+const REST = 0;
+const WEB = 1;
+
+const GET = 'GET';
+const POST = 'POST';
+const COOKIE = 'COOKIE';
const HTTP_VAR_INT = 0;
const HTTP_VAR_STR = 1;
@@ -47,17 +76,8 @@ define('DONATION_HOLIDAY_ALERT_ON', time() > 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 @@
-
-class EbookParsingException extends \Exception{
-}
diff --git a/lib/Email.php b/lib/Email.php
new file mode 100644
index 00000000..e8cbc596
--- /dev/null
+++ b/lib/Email.php
@@ -0,0 +1,88 @@
+
+use PHPMailer\PHPMailer\PHPMailer;
+use PHPMailer\PHPMailer\Exception;
+
+class Email{
+ public $To = '';
+ public $From = '';
+ public $FromName = '';
+ public $ReplyTo = '';
+ public $Subject = '';
+ public $Body = '';
+ public $TextBody = '';
+ public $Attachments = array();
+ public $PostmarkStream = null;
+
+ public function Send(): bool{
+ if($this->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 @@
+
+namespace Exceptions;
+
+class EbookParsingException extends SeException{
+}
diff --git a/lib/Exceptions/InvalidAuthorException.php b/lib/Exceptions/InvalidAuthorException.php
new file mode 100644
index 00000000..4ad6d751
--- /dev/null
+++ b/lib/Exceptions/InvalidAuthorException.php
@@ -0,0 +1,5 @@
+
+namespace Exceptions;
+
+class InvalidAuthorException extends SeException{
+}
diff --git a/lib/Exceptions/InvalidCaptchaException.php b/lib/Exceptions/InvalidCaptchaException.php
new file mode 100644
index 00000000..f39e582c
--- /dev/null
+++ b/lib/Exceptions/InvalidCaptchaException.php
@@ -0,0 +1,6 @@
+
+namespace Exceptions;
+
+class InvalidCaptchaException extends SeException{
+ protected $message = 'We couldn’t validate your CAPTCHA response.';
+}
diff --git a/lib/Exceptions/InvalidCollectionException.php b/lib/Exceptions/InvalidCollectionException.php
new file mode 100644
index 00000000..f36f3eec
--- /dev/null
+++ b/lib/Exceptions/InvalidCollectionException.php
@@ -0,0 +1,5 @@
+
+namespace Exceptions;
+
+class InvalidCollectionException extends SeException{
+}
diff --git a/lib/Exceptions/InvalidCredentialsException.php b/lib/Exceptions/InvalidCredentialsException.php
new file mode 100644
index 00000000..e1f44969
--- /dev/null
+++ b/lib/Exceptions/InvalidCredentialsException.php
@@ -0,0 +1,6 @@
+
+namespace Exceptions;
+
+class InvalidCredentialsException extends SeException{
+ protected $message = 'Invalid credentials.';
+}
diff --git a/lib/Exceptions/InvalidEbookException.php b/lib/Exceptions/InvalidEbookException.php
new file mode 100644
index 00000000..ef517d11
--- /dev/null
+++ b/lib/Exceptions/InvalidEbookException.php
@@ -0,0 +1,5 @@
+
+namespace Exceptions;
+
+class InvalidEbookException extends SeException{
+}
diff --git a/lib/Exceptions/InvalidEmailException.php b/lib/Exceptions/InvalidEmailException.php
new file mode 100644
index 00000000..af36cf67
--- /dev/null
+++ b/lib/Exceptions/InvalidEmailException.php
@@ -0,0 +1,6 @@
+
+namespace Exceptions;
+
+class InvalidEmailException extends SeException{
+ protected $message = 'We couldn’t understand your email address.';
+}
diff --git a/lib/Exceptions/InvalidNewsletterSubscriberException.php b/lib/Exceptions/InvalidNewsletterSubscriberException.php
new file mode 100644
index 00000000..d23dc7ef
--- /dev/null
+++ b/lib/Exceptions/InvalidNewsletterSubscriberException.php
@@ -0,0 +1,6 @@
+
+namespace Exceptions;
+
+class InvalidNewsletterSubscriberException extends SeException{
+ protected $message = 'We couldn’t find you in our newsletter subscribers list.';
+}
diff --git a/lib/Exceptions/InvalidRequestException.php b/lib/Exceptions/InvalidRequestException.php
new file mode 100644
index 00000000..d4bf6ce0
--- /dev/null
+++ b/lib/Exceptions/InvalidRequestException.php
@@ -0,0 +1,6 @@
+
+namespace Exceptions;
+
+class InvalidRequestException extends SeException{
+ protected $message = 'Invalid request.';
+}
diff --git a/lib/Exceptions/NewsletterRequiredException.php b/lib/Exceptions/NewsletterRequiredException.php
new file mode 100644
index 00000000..7b962fc5
--- /dev/null
+++ b/lib/Exceptions/NewsletterRequiredException.php
@@ -0,0 +1,6 @@
+
+namespace Exceptions;
+
+class NewsletterRequiredException extends SeException{
+ protected $message = 'You must select at least one newsletter to subscribe to.';
+}
diff --git a/lib/Exceptions/NewsletterSubscriberExistsException.php b/lib/Exceptions/NewsletterSubscriberExistsException.php
new file mode 100644
index 00000000..d6e3fb8f
--- /dev/null
+++ b/lib/Exceptions/NewsletterSubscriberExistsException.php
@@ -0,0 +1,6 @@
+
+namespace Exceptions;
+
+class NewsletterSubscriberExistsException extends SeException{
+ protected $message = 'You’re already subscribed to the newsletter.';
+}
diff --git a/lib/Exceptions/NoopException.php b/lib/Exceptions/NoopException.php
new file mode 100644
index 00000000..f904fc20
--- /dev/null
+++ b/lib/Exceptions/NoopException.php
@@ -0,0 +1,5 @@
+
+namespace Exceptions;
+
+class NoopException extends SeException{
+}
diff --git a/lib/Exceptions/SeException.php b/lib/Exceptions/SeException.php
new file mode 100644
index 00000000..14b2403f
--- /dev/null
+++ b/lib/Exceptions/SeException.php
@@ -0,0 +1,5 @@
+
+namespace Exceptions;
+
+class SeException extends \Exception{
+}
diff --git a/lib/SeeOtherEbookException.php b/lib/Exceptions/SeeOtherEbookException.php
similarity index 68%
rename from lib/SeeOtherEbookException.php
rename to lib/Exceptions/SeeOtherEbookException.php
index 780431c4..3ad92aaa 100644
--- a/lib/SeeOtherEbookException.php
+++ b/lib/Exceptions/SeeOtherEbookException.php
@@ -1,5 +1,7 @@
-class SeeOtherEbookException extends \Exception{
+namespace Exceptions;
+
+class SeeOtherEbookException extends SeException{
public $Url;
public function __construct(string $url = ''){
diff --git a/lib/Exceptions/ValidationException.php b/lib/Exceptions/ValidationException.php
new file mode 100644
index 00000000..e014ceb5
--- /dev/null
+++ b/lib/Exceptions/ValidationException.php
@@ -0,0 +1,63 @@
+
+namespace Exceptions;
+
+use function Safe\json_encode;
+
+class ValidationException extends SeException{
+ public $Exceptions = [];
+ public $HasExceptions = false;
+ public $IsFatal = false;
+
+ public function __toString(): string{
+ $output = '';
+ foreach($this->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 @@
-class WebhookException extends \Exception{
+namespace Exceptions;
+
+class WebhookException extends SeException{
public $PostData;
public function __construct(string $message = '', string $data = null){
diff --git a/lib/Formatter.php b/lib/Formatter.php
index 42121119..313d173d 100644
--- a/lib/Formatter.php
+++ b/lib/Formatter.php
@@ -32,7 +32,7 @@ class Formatter{
return $text;
}
- public static function ToPlainText(string $text): string{
+ public static function ToPlainText(?string $text): string{
return htmlspecialchars(trim($text), ENT_QUOTES, 'UTF-8');
}
}
diff --git a/lib/HttpInput.php b/lib/HttpInput.php
index 0dfae587..5e993ed0 100644
--- a/lib/HttpInput.php
+++ b/lib/HttpInput.php
@@ -1,6 +1,6 @@
class HttpInput{
- public static function Str(int $type, string $variable, bool $allowEmptyString = true, string $default = null): ?string{
+ public static function Str(string $type, string $variable, bool $allowEmptyString = true, string $default = null): ?string{
$var = self::GetHttpVar($variable, HTTP_VAR_STR, $type, $default);
if(is_array($var)){
@@ -14,15 +14,15 @@ class HttpInput{
return $var;
}
- public static function Int(int $type, string $variable, int $default = null): ?int{
+ public static function Int(string $type, string $variable, int $default = null): ?int{
return self::GetHttpVar($variable, HTTP_VAR_INT, $type, $default);
}
- public static function Bool(int $type, string $variable, bool $default = null): ?bool{
+ public static function Bool(string $type, string $variable, bool $default = null): ?bool{
return self::GetHttpVar($variable, HTTP_VAR_BOOL, $type, $default);
}
- public static function Dec(int $type, string $variable, float $default = null): ?float{
+ public static function Dec(string $type, string $variable, float $default = null): ?float{
return self::GetHttpVar($variable, HTTP_VAR_DEC, $type, $default);
}
@@ -33,7 +33,7 @@ class HttpInput{
return self::GetHttpVar($variable, HTTP_VAR_ARRAY, GET, $default);
}
- private static function GetHttpVar(string $variable, int $type, int $set, $default){
+ private static function GetHttpVar(string $variable, int $type, string $set, $default){
$vars = [];
switch($set){
diff --git a/lib/InvalidAuthorException.php b/lib/InvalidAuthorException.php
deleted file mode 100644
index 06cb37b9..00000000
--- a/lib/InvalidAuthorException.php
+++ /dev/null
@@ -1,3 +0,0 @@
-
-class InvalidAuthorException extends \Exception{
-}
diff --git a/lib/InvalidCollectionException.php b/lib/InvalidCollectionException.php
deleted file mode 100644
index 01789939..00000000
--- a/lib/InvalidCollectionException.php
+++ /dev/null
@@ -1,3 +0,0 @@
-
-class InvalidCollectionException extends \Exception{
-}
diff --git a/lib/InvalidEbookException.php b/lib/InvalidEbookException.php
deleted file mode 100644
index 68c403c1..00000000
--- a/lib/InvalidEbookException.php
+++ /dev/null
@@ -1,3 +0,0 @@
-
-class InvalidEbookException extends \Exception{
-}
diff --git a/lib/Logger.php b/lib/Logger.php
index 40f3269d..26790539 100644
--- a/lib/Logger.php
+++ b/lib/Logger.php
@@ -3,18 +3,27 @@ use function Safe\fopen;
use function Safe\fwrite;
use function Safe\fclose;
use function Safe\error_log;
+use function Safe\gmdate;
class Logger{
+ public static function WritePostmarkWebhookLogEntry(string $requestId, string $text): void{
+ self::WriteLogEntry(POSTMARK_WEBHOOK_LOG_FILE_PATH, $requestId . "\t" . $text);
+ }
+
public static function WriteGithubWebhookLogEntry(string $requestId, string $text): void{
+ self::WriteLogEntry(GITHUB_WEBHOOK_LOG_FILE_PATH, $requestId . "\t" . $text);
+ }
+
+ public static function WriteLogEntry(string $file, string $text): void{
try{
- $fp = fopen(GITHUB_WEBHOOK_LOG_FILE_PATH, 'a+');
+ $fp = fopen($file, 'a+');
}
catch(\Exception $ex){
- self::WriteErrorLogEntry('Couldn\'t open log file: ' . GITHUB_WEBHOOK_LOG_FILE_PATH . '. Exception: ' . vds($ex));
+ self::WriteErrorLogEntry('Couldn\'t open log file: ' . $file . '. Exception: ' . vds($ex));
return;
}
- fwrite($fp, gmdate('Y-m-d H:i:s') . "\t" . $requestId . "\t" . $text . "\n");
+ fwrite($fp, gmdate('Y-m-d H:i:s') . "\t" . $text . "\n");
fclose($fp);
}
diff --git a/lib/NewsletterSubscriber.php b/lib/NewsletterSubscriber.php
new file mode 100644
index 00000000..65e39a83
--- /dev/null
+++ b/lib/NewsletterSubscriber.php
@@ -0,0 +1,89 @@
+
+use Ramsey\Uuid\Uuid;
+
+class NewsletterSubscriber extends PropertiesBase{
+ public $NewsletterSubscriberId;
+ public $Uuid;
+ public $Email;
+ public $FirstName;
+ public $LastName;
+ public $IsConfirmed = false;
+ public $IsSubscribedToSummary = true;
+ public $IsSubscribedToNewsletter = true;
+ public $Timestamp;
+ protected $Url = null;
+
+ protected function GetUrl(): string{
+ if($this->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 @@
-
-class NoopException extends \Exception{
-}
diff --git a/lib/OpdsAcquisitionFeed.php b/lib/OpdsAcquisitionFeed.php
index 43ea50c3..86208382 100644
--- a/lib/OpdsAcquisitionFeed.php
+++ b/lib/OpdsAcquisitionFeed.php
@@ -1,6 +1,7 @@
use function Safe\file_get_contents;
use function Safe\file_put_contents;
+use function Safe\gmdate;
use function Safe\rename;
use function Safe\tempnam;
diff --git a/lib/OpdsFeed.php b/lib/OpdsFeed.php
index 37450341..c10b8fba 100644
--- a/lib/OpdsFeed.php
+++ b/lib/OpdsFeed.php
@@ -26,7 +26,7 @@ class OpdsFeed{
$xml->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 @@
use function Safe\file_get_contents;
use function Safe\file_put_contents;
+use function Safe\gmdate;
use function Safe\rename;
use function Safe\tempnam;
diff --git a/scripts/delete-unconfirmed-newsletter-subscribers.php b/scripts/delete-unconfirmed-newsletter-subscribers.php
new file mode 100644
index 00000000..4403b656
--- /dev/null
+++ b/scripts/delete-unconfirmed-newsletter-subscribers.php
@@ -0,0 +1,6 @@
+
+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');
+?>
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 @@
+
+