Add newsletter management functionality

This commit is contained in:
Alex Cabal 2022-03-20 17:40:19 -05:00
parent 90ee0a93c9
commit b0197d189a
57 changed files with 1017 additions and 143 deletions

View file

@ -6,7 +6,7 @@ PHP 7+ is required.
```shell ```shell
# Install Apache, PHP, PHP-FPM, and various other dependencies. # 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. # Create the site root and logs root and clone this repo into it.
sudo mkdir /standardebooks.org/ sudo mkdir /standardebooks.org/

View file

@ -8,6 +8,9 @@
"files": ["lib/Constants.php", "lib/CoreFunctions.php"] "files": ["lib/Constants.php", "lib/CoreFunctions.php"]
}, },
"require": { "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"
} }
} }

View file

@ -142,10 +142,9 @@ Define webroot /standardebooks.org/web
# In RewriteCond, RewriteRule gets evaluated BEFORE RewriteCond, so $1 refers to the first # In RewriteCond, RewriteRule gets evaluated BEFORE RewriteCond, so $1 refers to the first
# match in RewriteRule # match in RewriteRule
# Rewrite POST /some/url -> POST /some/url/post.php # Rewrite POST /some/url -> POST /some/url/post.php
RewriteCond %{REQUEST_METHOD} ^POST$ RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^(post|delete|put)$/"
RewriteCond %{DOCUMENT_ROOT}/$1/ -d RewriteCond %{DOCUMENT_ROOT}/$1/%1.php -f
RewriteCond %{DOCUMENT_ROOT}/$1/post.php -f RewriteRule ^([^\.]+)$ $1/%1.php [L]
RewriteRule ^([^\.]+)$ $1/post.php [L]
# In case of 404, serve the 404 page specified by ErrorDocument, not the default FPM error page. # 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 # 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. # If we ask for /opds/all?query=xyz, rewrite that to the search page.
RewriteCond %{QUERY_STRING} ^query= RewriteCond %{QUERY_STRING} ^query=
RewriteRule ^/opds/all.xml$ /opds/search.php [QSA] 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
</VirtualHost> </VirtualHost>
<VirtualHost *:80> <VirtualHost *:80>

View file

@ -141,10 +141,9 @@ Define webroot /standardebooks.org/web
# In RewriteCond, RewriteRule gets evaluated BEFORE RewriteCond, so $1 refers to the first # In RewriteCond, RewriteRule gets evaluated BEFORE RewriteCond, so $1 refers to the first
# match in RewriteRule # match in RewriteRule
# Rewrite POST /some/url -> POST /some/url/post.php # Rewrite POST /some/url -> POST /some/url/post.php
RewriteCond %{REQUEST_METHOD} ^POST$ RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^(post|delete|put)$/"
RewriteCond %{DOCUMENT_ROOT}/$1/ -d RewriteCond %{DOCUMENT_ROOT}/$1/%1.php -f
RewriteCond %{DOCUMENT_ROOT}/$1/post.php -f RewriteRule ^([^\.]+)$ $1/%1.php [L]
RewriteRule ^([^\.]+)$ $1/post.php [L]
# In case of 404, serve the 404 page specified by ErrorDocument, not the default FPM error page. # 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 # 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. # If we ask for /opds/all?query=xyz, rewrite that to the search page.
RewriteCond %{QUERY_STRING} ^query= RewriteCond %{QUERY_STRING} ^query=
RewriteRule ^/opds/all.xml$ /opds/search.php [QSA] 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
</VirtualHost> </VirtualHost>

View file

@ -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;

View file

@ -1,12 +1,29 @@
<? <?
// Auto-included by Composer in composer.json to satisfy PHPStan // Auto-included by Composer in composer.json to satisfy PHPStan
use function Safe\define; use function Safe\define;
use function Safe\file_get_contents;
use function Safe\gmdate;
use function Safe\strtotime; use function Safe\strtotime;
const SITE_STATUS_LIVE = 'live'; const SITE_STATUS_LIVE = 'live';
const SITE_STATUS_DEV = 'dev'; 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. 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_DATABASE = 'se';
const DATABASE_DEFAULT_HOST = 'localhost'; const DATABASE_DEFAULT_HOST = 'localhost';
@ -16,9 +33,21 @@ const SORT_AUTHOR_ALPHA = 'author-alpha';
const SORT_READING_EASE = 'reading-ease'; const SORT_READING_EASE = 'reading-ease';
const SORT_LENGTH = 'length'; const SORT_LENGTH = 'length';
const GET = 0; const CAPTCHA_IMAGE_HEIGHT = 72;
const POST = 1; const CAPTCHA_IMAGE_WIDTH = 230;
const COOKIE = 2;
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_INT = 0;
const HTTP_VAR_STR = 1; 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_ALERT_ON', DONATION_HOLIDAY_ALERT_ON || rand(1, 4) == 2);
define('DONATION_DRIVE_ON', false); 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_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_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 POSTMARK_WEBHOOK_LOG_FILE_PATH = '/var/log/local/webhooks-postmark.log'; // Must be writable by `www-data` Unix user.
const GITHUB_IGNORED_REPOS = ['tools', 'manual', 'web'];

View file

@ -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){ public function __construct(?string $defaultDatabase = null, string $host = 'localhost', ?string $user = null, string$password = '', bool $forceUtf8 = true, bool $require = true){
if($user === null){ if($user === null){
// Get the user running the script for local socket login // 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:'; $connectionString = 'mysql:';
@ -125,9 +128,9 @@ class DbConnection{
usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds 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){ elseif($ex->getCode() == '23000'){
$done = true; // Duplicate key, bubble this up without logging it so the business logic can handle it
// We reach here if Sphinx tries to get a record past its page limit. Just silently do nothing. throw($ex);
} }
else{ else{
$done = true; $done = true;
@ -139,16 +142,12 @@ class DbConnection{
Logger::WriteErrorLogEntry($ex->getMessage()); Logger::WriteErrorLogEntry($ex->getMessage());
Logger::WriteErrorLogEntry($preparedSql); Logger::WriteErrorLogEntry($preparedSql);
Logger::WriteErrorLogEntry(vds($params)); 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; return $result;
} }
@ -168,7 +167,7 @@ class DbConnection{
for($i = 0; $i < $columnCount; $i++){ for($i = 0; $i < $columnCount; $i++){
$metadata[$i] = $handle->getColumnMeta($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 // 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 // name starts with Is, Has, or Can and is followed by an uppercase letter
$metadata[$i]['native_type'] = 'BOOL'; $metadata[$i]['native_type'] = 'BOOL';

View file

@ -69,15 +69,15 @@ class Ebook{
} }
if(!is_dir($wwwFilesystemPath)){ 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)){ 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')){ 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; $this->WwwFilesystemPath = $wwwFilesystemPath;
@ -88,7 +88,7 @@ class Ebook{
// Get the SE identifier. // Get the SE identifier.
preg_match('|<dc:identifier[^>]*?>(.+?)</dc:identifier>|ius', $rawMetadata, $matches); preg_match('|<dc:identifier[^>]*?>(.+?)</dc:identifier>|ius', $rawMetadata, $matches);
if(sizeof($matches) != 2){ if(sizeof($matches) != 2){
throw new EbookParsingException('Invalid <dc:identifier> element.'); throw new Exceptions\EbookParsingException('Invalid <dc:identifier> element.');
} }
$this->Identifier = (string)$matches[1]; $this->Identifier = (string)$matches[1];
@ -175,7 +175,7 @@ class Ebook{
$this->Title = $this->NullIfEmpty($xml->xpath('/package/metadata/dc:title')); $this->Title = $this->NullIfEmpty($xml->xpath('/package/metadata/dc:title'));
if($this->Title === null){ if($this->Title === null){
throw new EbookParsingException('Invalid <dc:title> element.'); throw new Exceptions\EbookParsingException('Invalid <dc:title> element.');
} }
$this->Title = str_replace('\'', '', $this->Title); $this->Title = str_replace('\'', '', $this->Title);
@ -256,7 +256,7 @@ class Ebook{
} }
if(sizeof($this->Authors) == 0){ if(sizeof($this->Authors) == 0){
throw new EbookParsingException('Invalid <dc:creator> element.'); throw new Exceptions\EbookParsingException('Invalid <dc:creator> element.');
} }
$this->AuthorsUrl = preg_replace('|url:https://standardebooks.org/ebooks/([^/]+)/.*|ius', '/ebooks/\1', $this->Identifier); $this->AuthorsUrl = preg_replace('|url:https://standardebooks.org/ebooks/([^/]+)/.*|ius', '/ebooks/\1', $this->Identifier);

View file

@ -1,3 +0,0 @@
<?
class EbookParsingException extends \Exception{
}

88
lib/Email.php Normal file
View file

@ -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;
}
}
}

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class EbookParsingException extends SeException{
}

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class InvalidAuthorException extends SeException{
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidCaptchaException extends SeException{
protected $message = 'We couldnt validate your CAPTCHA response.';
}

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class InvalidCollectionException extends SeException{
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidCredentialsException extends SeException{
protected $message = 'Invalid credentials.';
}

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class InvalidEbookException extends SeException{
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidEmailException extends SeException{
protected $message = 'We couldnt understand your email address.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidNewsletterSubscriberException extends SeException{
protected $message = 'We couldnt find you in our newsletter subscribers list.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidRequestException extends SeException{
protected $message = 'Invalid request.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class NewsletterRequiredException extends SeException{
protected $message = 'You must select at least one newsletter to subscribe to.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class NewsletterSubscriberExistsException extends SeException{
protected $message = 'Youre already subscribed to the newsletter.';
}

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class NoopException extends SeException{
}

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class SeException extends \Exception{
}

View file

@ -1,5 +1,7 @@
<? <?
class SeeOtherEbookException extends \Exception{ namespace Exceptions;
class SeeOtherEbookException extends SeException{
public $Url; public $Url;
public function __construct(string $url = ''){ public function __construct(string $url = ''){

View file

@ -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;
}
}

View file

@ -1,5 +1,7 @@
<? <?
class WebhookException extends \Exception{ namespace Exceptions;
class WebhookException extends SeException{
public $PostData; public $PostData;
public function __construct(string $message = '', string $data = null){ public function __construct(string $message = '', string $data = null){

View file

@ -32,7 +32,7 @@ class Formatter{
return $text; return $text;
} }
public static function ToPlainText(string $text): string{ public static function ToPlainText(?string $text): string{
return htmlspecialchars(trim($text), ENT_QUOTES, 'UTF-8'); return htmlspecialchars(trim($text), ENT_QUOTES, 'UTF-8');
} }
} }

View file

@ -1,6 +1,6 @@
<? <?
class HttpInput{ 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); $var = self::GetHttpVar($variable, HTTP_VAR_STR, $type, $default);
if(is_array($var)){ if(is_array($var)){
@ -14,15 +14,15 @@ class HttpInput{
return $var; 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); 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); 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); return self::GetHttpVar($variable, HTTP_VAR_DEC, $type, $default);
} }
@ -33,7 +33,7 @@ class HttpInput{
return self::GetHttpVar($variable, HTTP_VAR_ARRAY, GET, $default); 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 = []; $vars = [];
switch($set){ switch($set){

View file

@ -1,3 +0,0 @@
<?
class InvalidAuthorException extends \Exception{
}

View file

@ -1,3 +0,0 @@
<?
class InvalidCollectionException extends \Exception{
}

View file

@ -1,3 +0,0 @@
<?
class InvalidEbookException extends \Exception{
}

View file

@ -3,18 +3,27 @@ use function Safe\fopen;
use function Safe\fwrite; use function Safe\fwrite;
use function Safe\fclose; use function Safe\fclose;
use function Safe\error_log; use function Safe\error_log;
use function Safe\gmdate;
class Logger{ 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{ 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{ try{
$fp = fopen(GITHUB_WEBHOOK_LOG_FILE_PATH, 'a+'); $fp = fopen($file, 'a+');
} }
catch(\Exception $ex){ 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; 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); fclose($fp);
} }

View file

@ -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];
}
}

View file

@ -1,3 +0,0 @@
<?
class NoopException extends \Exception{
}

View file

@ -1,6 +1,7 @@
<? <?
use function Safe\file_get_contents; use function Safe\file_get_contents;
use function Safe\file_put_contents; use function Safe\file_put_contents;
use function Safe\gmdate;
use function Safe\rename; use function Safe\rename;
use function Safe\tempnam; use function Safe\tempnam;

View file

@ -26,7 +26,7 @@ class OpdsFeed{
$xml->registerXPathNamespace('schema', 'http://schema.org/'); $xml->registerXPathNamespace('schema', 'http://schema.org/');
$entries = $xml->xpath('/feed/entry'); $entries = $xml->xpath('/feed/entry');
if($entries === false){ if(!$entries){
$entries = []; $entries = [];
} }
@ -37,7 +37,7 @@ class OpdsFeed{
// while updating it at the same time. // while updating it at the same time.
$elements = $xml->xpath('/feed/entry/updated'); $elements = $xml->xpath('/feed/entry/updated');
if($elements === false){ if(!$elements){
$elements = []; $elements = [];
} }
@ -71,7 +71,7 @@ class OpdsFeed{
$xml = new SimpleXMLElement(str_replace('xmlns=', 'ns=', file_get_contents($parentFilepath))); $xml = new SimpleXMLElement(str_replace('xmlns=', 'ns=', file_get_contents($parentFilepath)));
$feedEntries = $xml->xpath('/feed/entry[id="' . $this->Id . '"]/updated'); $feedEntries = $xml->xpath('/feed/entry[id="' . $this->Id . '"]/updated');
if($feedEntries === false){ if(!$feedEntries){
$feedEntries = []; $feedEntries = [];
} }

View file

@ -1,6 +1,7 @@
<? <?
use function Safe\file_get_contents; use function Safe\file_get_contents;
use function Safe\file_put_contents; use function Safe\file_put_contents;
use function Safe\gmdate;
use function Safe\rename; use function Safe\rename;
use function Safe\tempnam; use function Safe\tempnam;

View file

@ -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');
?>

View file

@ -3,6 +3,7 @@ require_once('/standardebooks.org/web/lib/Core.php');
use function Safe\file_get_contents; use function Safe\file_get_contents;
use function Safe\getopt; use function Safe\getopt;
use function Safe\gmdate;
use function Safe\krsort; use function Safe\krsort;
use function Safe\preg_replace; use function Safe\preg_replace;
use function Safe\strtotime; use function Safe\strtotime;

12
templates/EmailFooter.php Normal file
View file

@ -0,0 +1,12 @@
<div class="footer">
<p>
<a href="https://standardebooks.org/donate">Donate</a> &bull; <a href="https://standardebooks.org/contribute">Get involved</a> &bull; <a href="https://standardebooks.org/opds">OPDS</a> &bull; <a href="https://standardebooks.org/rss/new-releases">New releases RSS feed</a>
</p>
<p>
<a href="https://standardebooks.org">
<img src="https://standardebooks.org/images/logo-small.png" alt="The Standard Ebooks logo"/>
</a>
</p>
</div>
</body>
</html>

148
templates/EmailHeader.php Normal file
View file

@ -0,0 +1,148 @@
<?
$preheader = $preheader ?? null;
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title></title>
<style type="text/css">
@font-face{
font-family: "League Spartan";
src: url("https://standardebooks.org/fonts/league-spartan-bold.woff2") format("woff2");
font-weight: bold;
font-style: normal;
}
body{
border-radius: 1em;
font-family: "Georgia", serif;
hyphens: auto;
font-size: 18px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
color: #222222;
margin: auto;
max-width: 50em;
padding: 2em;
background-color: #E9E7E0;
}
<? if($preheader){ ?>
.preheader{
display:none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
color: #ffffff;
line-height: 1px;
height: 0;
width: 0;
opacity: 0;
overflow: hidden;
position: absolute;
top: -9999px;
}
<? } ?>
img.logo{
display: block;
margin: auto;
max-width: 100%;
width: 300px;
}
p{
margin: 1em auto;
}
h1,
h2{
font-family: "League Spartan", "Helvetica", "Arial", sans-serif;
font-weight: bold;
margin: 1em auto;
text-align: center;
}
h1{
border-bottom: 3px double #222;
font-size: 2em;
padding-bottom: 1em;
text-transform: uppercase;
line-height: 1;
}
h2{
font-size: 1.5em;
text-decoration: underline double;
margin-top: 2em;
}
a,
a:link,
a:visited{
color: #222;
text-decoration: underline;
}
a:hover{
color: #4f9d85;
}
.intro{
text-align: center;
}
.footer{
text-align: center;
border-top: 3px double #222;
padding-top: 1em;
margin-top: 2em;
text-transform: lowercase;
}
.footer img{
margin-top: 1em;
max-width: 110px;
}
a.button:link,
a.button:visited{
color: #fff;
}
a.button{
display: inline-block;
border: 1px solid rgba(0, 0, 0, .5);
font-style: normal;
box-sizing: border-box;
background-color: #4f9d85;
border-radius: 5px;
padding: 1em 2em;
color: #fff;
text-decoration: none;
font-family: "League Spartan", sans-serif;
font-weight: bold;
text-shadow: 1px 1px 0 rgba(0, 0, 0, .5);
box-shadow: 2px 2px 0 rgba(0, 0, 0, .5), 1px 1px 0px rgba(255,255,255, .5) inset;
position: relative;
text-transform: lowercase;
cursor: pointer;
min-height: calc(1.4em + 2em + 2px);
hyphens: none;
text-decoration: none;
}
a.button:hover{
background-color: #62bfa3;
}
.button-row{
text-align: center;
}
</style>
</head>
<body>
<? if($preheader){ ?><p class="preheader"><?= Formatter::ToPlainText($preheader) ?><? for($i = 0; $i < 150 - strlen($preheader); $i++){ ?>&zwnj;&nbsp;<? } ?></p><? } ?>

View file

@ -0,0 +1,9 @@
<?= Template::EmailHeader() ?>
<h1>Confirm your newsletter subscription</h1>
<p>Thank you for subscribing to the Standard Ebooks newsletter!</p>
<p>Please use the button below to confirm your newsletter subscription. You wont receive our newsletters until you confirm your subscription.</p>
<p class="button-row">
<a href="<?= $subscriber->Url ?>/confirm" class="button">Yes, subscribe me to the newsletter</a>
</p>
<p>If you didnt subscribe to our newsletter, or youre not sure why you received this email, you can safely delete it and you wont receive any more email from us.</p>
<?= Template::EmailFooter() ?>

View file

@ -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 wont receive our newsletters until you confirm your subscription.
<<?= $subscriber->Url ?>/confirm>
If you didnt subscribe to our newsletter, or youre not sure why you received this email, you can safely delete it and you wont receive any more email from us.

22
templates/Error.php Normal file
View file

@ -0,0 +1,22 @@
<?
if(!$exception){
return;
}
$exceptions = [];
if($exception instanceof Exceptions\ValidationException){
$exceptions = $exception->Exceptions;
}
else{
$exceptions[] = $exception;
}
?>
<ul class="error">
<? foreach($exceptions as $ex){ ?>
<li>
<p><? $message = $ex->getMessage(); if($message == ''){ $message = 'An error occurred.'; } ?><?= Formatter::ToPlainText($message) ?></p>
</li>
<? } ?>
</ul>

View file

@ -625,6 +625,19 @@ input[type="checkbox"]:focus{
outline: none; 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, .ebooks nav li.highlighted a:focus,
a.button:focus, a.button:focus,
input[type="email"]:focus, input[type="email"]:focus,
@ -1471,6 +1484,10 @@ input::placeholder{
color: rgba(0, 0, 0, .75); color: rgba(0, 0, 0, .75);
} }
label.automation-test{
display: none;
}
label.email, label.email,
label.search{ label.search{
display: block; display: block;
@ -1498,7 +1515,8 @@ input[type="search"]{
box-shadow: 1px 1px 0 rgba(255, 255, 255, .5) inset; 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; padding-left: 2.5rem;
} }
@ -1987,7 +2005,7 @@ article.ebook section aside.donation p::after{
left: -5000px; left: -5000px;
} }
form[action*="list-manage.com"]{ form[action="/newsletter/subscribers"]{
display: grid; display: grid;
grid-gap: 1rem; grid-gap: 1rem;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -1995,21 +2013,32 @@ form[action*="list-manage.com"]{
margin-bottom: 0; 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; 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; list-style: none;
} }
form[action*="list-manage.com"] button{ form[action="/newsletter/subscribers"] button{
grid-column: 2; grid-column: 2;
justify-self: end; justify-self: end;
margin-left: 0; margin-left: 0;
} }
form[action*="list-manage.com"] fieldset{ form[action="/newsletter/subscribers"] fieldset{
margin-top: 1rem; margin-top: 1rem;
grid-column: 1 / span 2; grid-column: 1 / span 2;
} }
@ -2761,8 +2790,9 @@ aside button.close:active{
margin-top: 0; margin-top: 0;
} }
form[action*="list-manage.com"] label.text, form[action="/newsletter/subscribers"] label.text,
form[action*="list-manage.com"] fieldset{ form[action="/newsletter/subscribers"] label.captcha,
form[action="/newsletter/subscribers"] fieldset{
grid-column: 1 / span 2; grid-column: 1 / span 2;
} }

View file

@ -7,16 +7,16 @@ try{
if($urlPath == '' || mb_stripos($wwwFilesystemPath, EBOOKS_DIST_PATH) !== 0 || !is_dir($wwwFilesystemPath)){ 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 // Ensure the path exists and that the root is in our www directory
throw new InvalidAuthorException(); throw new Exceptions\InvalidAuthorException();
} }
$ebooks = Library::GetEbooksByAuthor($wwwFilesystemPath); $ebooks = Library::GetEbooksByAuthor($wwwFilesystemPath);
if(sizeof($ebooks) == 0){ if(sizeof($ebooks) == 0){
throw new InvalidAuthorException(); throw new Exceptions\InvalidAuthorException();
} }
} }
catch(InvalidAuthorException $ex){ catch(Exceptions\InvalidAuthorException $ex){
http_response_code(404); http_response_code(404);
include(WEB_ROOT . '/404.php'); include(WEB_ROOT . '/404.php');
exit(); exit();

View file

@ -14,7 +14,7 @@ try{
if($urlPath == '' || mb_stripos($wwwFilesystemPath, EBOOKS_DIST_PATH) !== 0){ if($urlPath == '' || mb_stripos($wwwFilesystemPath, EBOOKS_DIST_PATH) !== 0){
// Ensure the path exists and that the root is in our www directory // 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? // 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. // 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. // 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())){ 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++; $i++;
} }
} }
catch(SeeOtherEbookException $ex){ catch(Exceptions\SeeOtherEbookException $ex){
http_response_code(301); http_response_code(301);
header('Location: ' . $ex->Url); header('Location: ' . $ex->Url);
exit(); exit();
} }
catch(InvalidEbookException $ex){ catch(Exceptions\InvalidEbookException $ex){
http_response_code(404); http_response_code(404);
include(WEB_ROOT . '/404.php'); include(WEB_ROOT . '/404.php');
exit(); exit();

View file

@ -83,7 +83,7 @@ try{
$pageHeader = 'Free ebooks in the ' . Formatter::ToPlainText($collectionName) . ' ' . $collectionType; $pageHeader = 'Free ebooks in the ' . Formatter::ToPlainText($collectionName) . ' ' . $collectionType;
} }
else{ else{
throw new InvalidCollectionException(); throw new Exceptions\InvalidCollectionException();
} }
} }
else{ else{
@ -127,7 +127,7 @@ try{
$queryString = preg_replace('/^&amp;/ius', '', $queryString); $queryString = preg_replace('/^&amp;/ius', '', $queryString);
} }
catch(InvalidCollectionException $ex){ catch(Exceptions\InvalidCollectionException $ex){
http_response_code(404); http_response_code(404);
include(WEB_ROOT . '/404.php'); include(WEB_ROOT . '/404.php');
exit(); exit();

17
www/images/captcha.php Normal file
View file

@ -0,0 +1,17 @@
<?
use function Safe\session_unset;
use Gregwar\Captcha\CaptchaBuilder;
require_once('Core.php');
session_start();
header('Content-type: image/jpeg');
$builder = new CaptchaBuilder;
$builder->build(CAPTCHA_IMAGE_WIDTH, CAPTCHA_IMAGE_HEIGHT);
$_SESSION['captcha'] = $builder->getPhrase();
$builder->output();

View file

@ -1,43 +0,0 @@
<?
require_once('Core.php');
?><?= Template::Header(['title' => 'Subscribe to the Standard Ebooks newsletter', 'highlight' => 'newsletter', 'description' => 'Subscribe to the Standard Ebooks newsletter to receive occasional updates about the project.']) ?>
<main>
<article class="has-hero">
<hgroup>
<h1>Subscribe to the Newsletter</h1>
<h2>to receive missives from the vanguard of digital literature</h2>
</hgroup>
<picture>
<source srcset="/images/the-newsletter@2x.avif 2x, /images/the-newsletter.avif 1x" type="image/avif"/>
<source srcset="/images/the-newsletter@2x.jpg 2x, /images/the-newsletter.jpg 1x" type="image/jpg"/>
<img src="/images/the-newsletter@2x.jpg" alt="An old man in Renaissance-era costume reading a sheet of paper."/>
</picture>
<p>Subscribe to receive news, updates, and more from Standard Ebooks. Your information will never be shared, and you can unsubscribe at any time.</p>
<form action="https://standardebooks.us7.list-manage.com/subscribe/post?u=da307dcb73c74f6a3d597f056&amp;id=f8832654aa" method="post">
<!-- real people should not fill this in and expect good things - do not remove this or risk form bot signups-->
<div class="anti-spam" aria-hidden="true"><input type="text" name="b_da307dcb73c74f6a3d597f056_f8832654aa" tabindex="-1" value=""/></div>
<label class="email">Email
<input type="email" name="EMAIL" value="" required="required"/>
</label>
<label class="text">First name
<input type="text" name="FNAME" value=""/>
</label>
<label class="text">Last name
<input type="text" name="LNAME" value=""/>
</label>
<fieldset>
<p>What kind of email would you like to receive?</p>
<ul>
<li>
<label class="checkbox"><input type="checkbox" value="1" name="group[78748][1]" checked="checked"/>The occasional Standard Ebooks newsletter</label>
</li>
<li>
<label class="checkbox"><input type="checkbox" value="2" name="group[78748][2]" checked="checked"/>A monthly summary of new ebook releases</label>
</li>
</ul>
</fieldset>
<button>Subscribe</button>
</form>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,21 @@
<?
require_once('Core.php');
try{
$subscriber = NewsletterSubscriber::Get(HttpInput::Str(GET, 'uuid') ?? '');
$subscriber->Confirm();
}
catch(Exceptions\InvalidNewsletterSubscriberException $ex){
http_response_code(404);
include(WEB_ROOT . '/404.php');
exit();
}
?><?= Template::Header(['title' => 'Your subscription to the Standard Ebooks newsletter has been confirmed', 'highlight' => 'newsletter', 'description' => 'Your subscription to the Standard Ebooks newsletter has been confirmed.']) ?>
<main>
<article>
<h1>Your subscription is confirmed!</h1>
<p>Thank you! Youll now receive Standard Ebooks email newsletters.</p>
<p>To unsubscribe, simply follow the link at the bottom of any of our newsletters, or <a href="<?= $subscriber->Url ?>/delete">click here</a>.</p>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,41 @@
<?
require_once('Core.php');
use function Safe\preg_match;
$requestType = preg_match('/\btext\/html\b/ius', $_SERVER['HTTP_ACCEPT']) ? WEB : REST;
try{
// We may use GET if we're called from an unsubscribe link in an email
if(!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'GET'])){
throw new Exceptions\InvalidRequestException();
}
$subscriber = NewsletterSubscriber::Get(HttpInput::Str(GET, 'uuid') ?? '');
$subscriber->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();
}
?><?= Template::Header(['title' => 'Youve unsubscribed from the Standard Ebooks newsletter', 'highlight' => 'newsletter', 'description' => 'Youve unsubscribed from the Standard Ebooks newsletter.']) ?>
<main>
<article>
<h1>Youve been unsubscribed</h1>
<p>Youll no longer receive Standard Ebooks email newsletters. Sorry to see you go!</p>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,67 @@
<?
require_once('Core.php');
use function Safe\session_unset;
session_start();
$subscriber = $_SESSION['subscriber'] ?? new NewsletterSubscriber();
$exception = $_SESSION['exception'] ?? null;
if($exception){
http_response_code(400);
session_unset();
}
?><?= Template::Header(['title' => 'Subscribe to the Standard Ebooks newsletter', 'highlight' => 'newsletter', 'description' => 'Subscribe to the Standard Ebooks newsletter to receive occasional updates about the project.']) ?>
<main>
<article class="has-hero">
<hgroup>
<h1>Subscribe to the Newsletter</h1>
<h2>to receive missives from the vanguard of digital literature</h2>
</hgroup>
<picture>
<source srcset="/images/the-newsletter@2x.avif 2x, /images/the-newsletter.avif 1x" type="image/avif"/>
<source srcset="/images/the-newsletter@2x.jpg 2x, /images/the-newsletter.jpg 1x" type="image/jpg"/>
<img src="/images/the-newsletter@2x.jpg" alt="An old man in Renaissance-era costume reading a sheet of paper."/>
</picture>
<p>Subscribe to receive news, updates, and more from Standard Ebooks. Your information will never be shared, and you can unsubscribe at any time.</p>
<?= Template::Error(['exception' => $exception]) ?>
<form action="/newsletter/subscribers" method="post">
<label class="automation-test"><? /* Test for spam bots filling out all fields */ ?>
<input type="text" name="automationtest" value="" maxlength="80" />
</label>
<label class="email">Email
<input type="email" name="email" value="<?= Formatter::ToPlainText($subscriber->Email) ?>" maxlength="80" required="required" />
</label>
<label class="text">First name
<input type="text" name="firstname" autocomplete="given-name" maxlength="80" value="<?= Formatter::ToPlainText($subscriber->FirstName) ?>" />
</label>
<label class="text">Last name
<input type="text" name="lastname" autocomplete="family-name" maxlength="80" value="<?= Formatter::ToPlainText($subscriber->LastName) ?>" />
</label>
<label class="captcha">
Type the letters in the image
<div>
<input type="text" name="captcha" required="required" />
<img src="/images/captcha" alt="A visual CAPTCHA." height="<?= CAPTCHA_IMAGE_HEIGHT ?>" width="<?= CAPTCHA_IMAGE_WIDTH ?>" />
</div>
</label>
<fieldset>
<p>What kind of email would you like to receive?</p>
<ul>
<li>
<label class="checkbox"><input type="checkbox" value="1" name="newsletter"<? if($subscriber->IsSubscribedToNewsletter){ ?> checked="checked"<? } ?> />The occasional Standard Ebooks newsletter</label>
</li>
<li>
<label class="checkbox"><input type="checkbox" value="1" name="monthlysummary"<? if($subscriber->IsSubscribedToSummary){ ?> checked="checked"<? } ?> />A monthly summary of new ebook releases</label>
</li>
</ul>
</fieldset>
<button>Subscribe</button>
</form>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,77 @@
<?
require_once('Core.php');
use function Safe\preg_match;
use function Safe\session_unset;
if($_SERVER['REQUEST_METHOD'] != 'POST'){
http_response_code(405);
exit();
}
session_start();
$requestType = preg_match('/\btext\/html\b/ius', $_SERVER['HTTP_ACCEPT']) ? WEB : REST;
$subscriber = new NewsletterSubscriber();
if(HttpInput::Str(POST, 'automationtest', false)){
// A bot filled out this form field, which should always be empty. Pretend like we succeeded.
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);
}
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);
}
}

View file

@ -0,0 +1,12 @@
<?
require_once('Core.php');
?><?= Template::Header(['title' => 'Subscribe to the Standard Ebooks newsletter', 'highlight' => 'newsletter', 'description' => 'Subscribe to the Standard Ebooks newsletter to receive occasional updates about the project.']) ?>
<main>
<article>
<h1>Almost done!</h1>
<p>Please check your email inbox for a confirmation email containing a link to finalize your subscription to our newsletter.</p>
<p>Your subscription wont be activated until you click that link—this helps us prevent spam. Thank you!</p>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -19,7 +19,7 @@ try{
Logger::WriteGithubWebhookLogEntry($requestId, 'Received GitHub webhook.'); Logger::WriteGithubWebhookLogEntry($requestId, 'Received GitHub webhook.');
if($_SERVER['REQUEST_METHOD'] != 'POST'){ if($_SERVER['REQUEST_METHOD'] != 'POST'){
throw new WebhookException('Expected HTTP POST.'); throw new Exceptions\WebhookException('Expected HTTP POST.');
} }
$post = file_get_contents('php://input') ?: ''; $post = file_get_contents('php://input') ?: '';
@ -30,12 +30,12 @@ try{
$hash = $splitHash[1]; $hash = $splitHash[1];
if(!hash_equals($hash, hash_hmac($hashAlgorithm, $post, preg_replace("/[\r\n]/ius", '', file_get_contents(GITHUB_SECRET_FILE_PATH) ?: '') ?? ''))){ 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. // Sanity check before we continue.
if(!array_key_exists('HTTP_X_GITHUB_EVENT', $_SERVER)){ 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); $data = json_decode($post, true);
@ -45,20 +45,20 @@ try{
case 'ping': case 'ping':
// Silence on success. // Silence on success.
Logger::WriteGithubWebhookLogEntry($requestId, 'Event type: ping.'); Logger::WriteGithubWebhookLogEntry($requestId, 'Event type: ping.');
throw new NoopException(); throw new Exceptions\NoopException();
case 'push': case 'push':
Logger::WriteGithubWebhookLogEntry($requestId, 'Event type: push.'); Logger::WriteGithubWebhookLogEntry($requestId, 'Event type: push.');
// Get the ebook ID. PHP doesn't throw exceptions on invalid array indexes, so check that first. // 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'])){ 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'], '/'); $repoName = trim($data['repository']['name'], '/');
if(in_array($repoName, GITHUB_IGNORED_REPOS)){ if(in_array($repoName, GITHUB_IGNORED_REPOS)){
Logger::WriteGithubWebhookLogEntry($requestId, 'Repo is in ignore list, no action taken.'); 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. // Get the filesystem path for the ebook.
@ -73,7 +73,7 @@ try{
} }
if(!file_exists($dir . '/HEAD')){ 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 == ''){ if($lastCommitSha1 == ''){
Logger::WriteGithubWebhookLogEntry($requestId, 'Error getting last local commit. Output: ' . $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{ else{
if($data['after'] == $lastCommitSha1){ if($data['after'] == $lastCommitSha1){
// This commit is already in our local repo, so silent success // This commit is already in our local repo, so silent success
Logger::WriteGithubWebhookLogEntry($requestId, 'Local repo already in sync, no action taken.'); 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); exec('sudo --set-home --user se-vcs-bot ' . SITE_ROOT . '/scripts/pull-from-github ' . escapeshellarg($dir) . ' 2>&1', $output, $returnCode);
if($returnCode != 0){ if($returnCode != 0){
Logger::WriteGithubWebhookLogEntry($requestId, 'Error pulling from GitHub. Output: ' . implode("\n", $output)); 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{ else{
Logger::WriteGithubWebhookLogEntry($requestId, '`git pull` from GitHub complete.'); 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); 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){ if($returnCode != 0){
Logger::WriteGithubWebhookLogEntry($requestId, 'Error queueing ebook for deployment to web. Output: ' . implode("\n", $output)); 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{ else{
Logger::WriteGithubWebhookLogEntry($requestId, 'Queue for deployment to web complete.'); Logger::WriteGithubWebhookLogEntry($requestId, 'Queue for deployment to web complete.');
@ -128,13 +128,17 @@ try{
break; break;
default: default:
throw new WebhookException('Unrecognized GitHub webhook event.', $post); throw new Exceptions\WebhookException('Unrecognized GitHub webhook event.', $post);
} }
// "Success, no content" // "Success, no content"
http_response_code(204); 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! // Uh oh, something went wrong!
// Log detailed error and debugging information locally. // Log detailed error and debugging information locally.
Logger::WriteGithubWebhookLogEntry($requestId, 'Webhook failed! Error: ' . $ex->getMessage()); Logger::WriteGithubWebhookLogEntry($requestId, 'Webhook failed! Error: ' . $ex->getMessage());
@ -146,7 +150,7 @@ catch(WebhookException $ex){
// "Client error" // "Client error"
http_response_code(400); 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. // 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. // For example, we received a request for a known repo for which we must ignore requests.

87
www/webhooks/postmark.php Normal file
View file

@ -0,0 +1,87 @@
<?
require_once('Core.php');
use function Safe\curl_exec;
use function Safe\curl_init;
use function Safe\curl_setopt;
use function Safe\file_get_contents;
use function Safe\json_decode;
use function Safe\substr;
// Get a semi-random ID to identify this request within the log.
$requestId = substr(sha1(time() . rand()), 0, 8);
try{
Logger::WritePostmarkWebhookLogEntry($requestId, 'Received Postmark webhook.');
if($_SERVER['REQUEST_METHOD'] != 'POST'){
throw new Exceptions\WebhookException('Expected HTTP POST.');
}
$apiKey = trim(file_get_contents(SITE_ROOT . '/config/secrets/webhooks@postmarkapp.com')) ?: '';
// Ensure this webhook actually came from Postmark
if($apiKey != ($_SERVER['HTTP_X_SE_KEY'] ?? '')){
throw new Exceptions\InvalidCredentialsException();
}
$post = json_decode(file_get_contents('php://input') ?: '');
if(!$post || !property_exists($post, 'RecordType')){
throw new Exceptions\WebhookException('Couldn\'t understand HTTP request.', $post);
}
if($post->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);
}